@loopress/cli 0.27.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 +267 -175
- 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 +2 -0
- package/dist/commands/plugin/add.js +6 -3
- 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 +69 -8
- package/dist/commands/pull.js +2 -2
- package/dist/commands/push.d.ts +1 -0
- package/dist/commands/push.js +37 -3
- 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/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 +3 -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 +3 -1
- package/dist/lib/diff-command.d.ts +1 -0
- package/dist/lib/diff-command.js +6 -4
- 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/validate-local.js +6 -0
- package/dist/lib/wp-client.d.ts +4 -1
- package/dist/lib/wp-client.js +25 -1
- 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/page-format.d.ts +8 -0
- package/dist/utils/page-format.js +34 -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 +526 -324
- package/package.json +3 -2
- 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
|
@@ -2,9 +2,9 @@ import { confirm } from '@inquirer/prompts';
|
|
|
2
2
|
import { Args } from '@oclif/core';
|
|
3
3
|
import { Buffer } from 'node:buffer';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
|
-
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
|
-
import { APP_CONFIG_FILENAME } from '../../lib/app-manifest.js';
|
|
7
|
+
import { APP_CONFIG_FILENAME, parseAppConfig } from '../../lib/app-manifest.js';
|
|
8
8
|
import { LoopressCommand } from '../../lib/base.js';
|
|
9
9
|
import { isInteractive } from '../../lib/interactive.js';
|
|
10
10
|
import { pluralize } from '../../utils/pluralize.js';
|
|
@@ -60,14 +60,27 @@ export default class Pull extends LoopressCommand {
|
|
|
60
60
|
}
|
|
61
61
|
async pullApp(path, name) {
|
|
62
62
|
const manifest = await this.wp.get(`loopress/v1/apps/${name}/manifest`);
|
|
63
|
-
const
|
|
63
|
+
const appDir = join(path, name);
|
|
64
|
+
const configPath = join(appDir, APP_CONFIG_FILENAME);
|
|
65
|
+
// WordPress doesn't know where the app builds to (assetsDir) or whether its entry is explicit:
|
|
66
|
+
// keep what the local config says, only refresh what the server is the source of truth for.
|
|
67
|
+
const local = existsSync(configPath) ? parseAppConfig(await readFile(configPath, 'utf8'), appDir) : {};
|
|
68
|
+
const assetsDir = local.assetsDir ?? 'dist';
|
|
69
|
+
const distDir = join(appDir, assetsDir);
|
|
64
70
|
for (const file of manifest.files) {
|
|
65
71
|
const { content } = await this.wp.get(`loopress/v1/apps/${name}/asset?path=${encodeURIComponent(file.path)}`);
|
|
66
72
|
const target = join(distDir, file.path);
|
|
67
73
|
await mkdir(dirname(target), { recursive: true });
|
|
68
74
|
await writeFile(target, Buffer.from(content, 'base64'));
|
|
69
75
|
}
|
|
70
|
-
await writeFile(
|
|
76
|
+
await writeFile(configPath, JSON.stringify({
|
|
77
|
+
...local,
|
|
78
|
+
assetsDir,
|
|
79
|
+
crossOrigin: manifest.crossOrigin ?? undefined, // dropped by JSON.stringify when unset
|
|
80
|
+
mountSelector: manifest.mountSelector,
|
|
81
|
+
name: manifest.name,
|
|
82
|
+
routing: manifest.routing,
|
|
83
|
+
}, null, 2) + '\n');
|
|
71
84
|
this.log(` ${name}: ${pluralize(manifest.files.length, 'file')} (build ${manifest.buildId})`);
|
|
72
85
|
return { files: manifest.files.length, name };
|
|
73
86
|
}
|
|
@@ -18,11 +18,14 @@ export default class Push extends PushCommand {
|
|
|
18
18
|
static flags: {
|
|
19
19
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
20
20
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
21
|
+
build: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
21
22
|
};
|
|
23
|
+
private build;
|
|
22
24
|
run(): Promise<PushResult>;
|
|
23
25
|
private appDirNames;
|
|
24
26
|
private pushApp;
|
|
25
27
|
private remoteFiles;
|
|
26
28
|
private uploadAsset;
|
|
29
|
+
private warnIfCrossOriginIgnored;
|
|
27
30
|
}
|
|
28
31
|
export {};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { Args } from '@oclif/core';
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { readdir, readFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { buildApp } from '../../lib/app-build.js';
|
|
5
6
|
import { APP_CONFIG_FILENAME, diffFiles, loadAppManifest, } from '../../lib/app-manifest.js';
|
|
6
7
|
import { PushCommand } from '../../lib/push-command.js';
|
|
7
8
|
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
@@ -10,15 +11,22 @@ export default class Push extends PushCommand {
|
|
|
10
11
|
static args = {
|
|
11
12
|
name: Args.string({ description: 'Push only this app (defaults to every app in the directory)' }),
|
|
12
13
|
};
|
|
13
|
-
static description = '
|
|
14
|
+
static description = 'Build and push single-page app bundles to WordPress. Each app is an `apps/<name>/` directory holding a loopress.app.json and a dist/ folder. When the app has a package.json with a `build` script, it runs first (use --no-build to skip it). Only files whose content changed are uploaded.';
|
|
14
15
|
static enableJsonFlag = true;
|
|
15
|
-
static examples = ['$ lps app push', '$ lps app push search'];
|
|
16
|
+
static examples = ['$ lps app push', '$ lps app push search', '$ lps app push --no-build'];
|
|
16
17
|
static flags = {
|
|
18
|
+
build: Flags.boolean({
|
|
19
|
+
allowNo: true,
|
|
20
|
+
default: true,
|
|
21
|
+
description: "Run the app's package.json build script before pushing",
|
|
22
|
+
}),
|
|
17
23
|
...PushCommand.dryRunFlag,
|
|
18
24
|
...PushCommand.yesFlag,
|
|
19
25
|
};
|
|
26
|
+
build = true;
|
|
20
27
|
async run() {
|
|
21
|
-
const { args } = await this.parse(Push);
|
|
28
|
+
const { args, flags } = await this.parse(Push);
|
|
29
|
+
this.build = flags.build;
|
|
22
30
|
const { url } = this.siteConfig;
|
|
23
31
|
const path = this.resolveAppsPath();
|
|
24
32
|
this.log(`Pushing apps to ${url}`);
|
|
@@ -71,6 +79,13 @@ export default class Push extends PushCommand {
|
|
|
71
79
|
let manifest;
|
|
72
80
|
let distDir;
|
|
73
81
|
try {
|
|
82
|
+
// Built on dry runs too: the preview diffs dist/, so a stale one would make it lie.
|
|
83
|
+
if (this.build) {
|
|
84
|
+
await buildApp(appDir, (command) => {
|
|
85
|
+
if (task)
|
|
86
|
+
task.output = `${dirName}: building (${command})`;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
74
89
|
;
|
|
75
90
|
({ distDir, manifest } = await loadAppManifest(appDir, dirName));
|
|
76
91
|
}
|
|
@@ -93,12 +108,14 @@ export default class Push extends PushCommand {
|
|
|
93
108
|
task.output = `${manifest.name}: uploading ${file.path} (${index + 1}/${changed.length})`;
|
|
94
109
|
await this.uploadAsset(manifest.name, distDir, file);
|
|
95
110
|
}
|
|
111
|
+
let committed;
|
|
96
112
|
try {
|
|
97
|
-
await this.wp.post(`loopress/v1/apps/${manifest.name}/commit`, manifest);
|
|
113
|
+
committed = await this.wp.post(`loopress/v1/apps/${manifest.name}/commit`, manifest);
|
|
98
114
|
}
|
|
99
115
|
catch (error) {
|
|
100
116
|
this.reportTaskFailure(`${manifest.name}: commit failed: ${error.message}`, error, task);
|
|
101
117
|
}
|
|
118
|
+
this.warnIfCrossOriginIgnored(manifest, committed);
|
|
102
119
|
if (task) {
|
|
103
120
|
task.output =
|
|
104
121
|
changed.length === 0
|
|
@@ -127,4 +144,11 @@ export default class Push extends PushCommand {
|
|
|
127
144
|
this.reportTaskFailure(`${name}: failed to upload ${file.path}: ${error.message}`, error);
|
|
128
145
|
}
|
|
129
146
|
}
|
|
147
|
+
// An older plugin, or an app .htaccess the site owner manages, commits the build without the
|
|
148
|
+
// policy: say so rather than let the app look protected when it is not.
|
|
149
|
+
warnIfCrossOriginIgnored(manifest, committed) {
|
|
150
|
+
if (manifest.crossOrigin && committed?.crossOrigin !== manifest.crossOrigin) {
|
|
151
|
+
this.warn(`${manifest.name}: crossOrigin "${manifest.crossOrigin}" was not applied: either the Loopress plugin on this site is too old (update it, then push again) or wp-content/loopress/apps/${manifest.name}/.htaccess is managed by hand and was left untouched.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
130
154
|
}
|
|
@@ -4,26 +4,8 @@ import { readFile } from 'node:fs/promises';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { PushCommand } from '../../lib/push-command.js';
|
|
6
6
|
import { isTimeoutError } from '../../lib/wp-client.js';
|
|
7
|
+
import { toIntent } from '../../utils/composer.js';
|
|
7
8
|
import { parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
|
|
8
|
-
// Split a composer.json `require` map into the three intent namespaces the sync endpoint
|
|
9
|
-
// understands. `composer/installers` is owned by the server scaffold, never sent.
|
|
10
|
-
function toIntent(require) {
|
|
11
|
-
const intent = { libraries: {}, plugins: {}, themes: {} };
|
|
12
|
-
for (const [name, constraint] of Object.entries(require)) {
|
|
13
|
-
if (name === 'composer/installers')
|
|
14
|
-
continue;
|
|
15
|
-
if (name.startsWith('wpackagist-plugin/')) {
|
|
16
|
-
intent.plugins[name.slice('wpackagist-plugin/'.length)] = constraint;
|
|
17
|
-
}
|
|
18
|
-
else if (name.startsWith('wpackagist-theme/')) {
|
|
19
|
-
intent.themes[name.slice('wpackagist-theme/'.length)] = constraint;
|
|
20
|
-
}
|
|
21
|
-
else {
|
|
22
|
-
intent.libraries[name] = constraint;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return intent;
|
|
26
|
-
}
|
|
27
9
|
export default class ComposerPush extends PushCommand {
|
|
28
10
|
static description = 'Push composer.json to WordPress and run Composer to resolve and install dependencies';
|
|
29
11
|
static enableJsonFlag = true;
|
package/dist/commands/diff.js
CHANGED
|
@@ -7,7 +7,7 @@ const RESOURCES = [...RESOURCE_STATE_PROVIDERS.map((provider) => provider.resour
|
|
|
7
7
|
export default class Diff extends DiffCommand {
|
|
8
8
|
static description = [
|
|
9
9
|
'Show what differs between your local tracked files and a WordPress environment, or between two environments.',
|
|
10
|
-
'Covers snippets, forms, ACF, API routes, Hooks, pages, SEO, Options, Menus, Theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
|
|
10
|
+
'Covers snippets, forms, ACF, API routes, Hooks, templates, template parts, pages, SEO, Options, Menus, Theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
|
|
11
11
|
'Exit code: 0 in sync, 1 on drift, 2 when a resource could not be compared, so it doubles as a CI drift gate.',
|
|
12
12
|
].join(' ');
|
|
13
13
|
static enableJsonFlag = true;
|
package/dist/commands/init.js
CHANGED
|
@@ -21,6 +21,8 @@ const FEATURES = [
|
|
|
21
21
|
{ dir: 'hooks', key: 'hooksDir', label: 'Hooks (actions, filters, cron)' },
|
|
22
22
|
{ dir: 'apps', key: 'appsDir', label: 'Single-page apps' },
|
|
23
23
|
{ dir: 'pages', key: 'pageDir', label: 'Static pages' },
|
|
24
|
+
{ dir: 'templates', key: 'templateDir', label: 'Block templates' },
|
|
25
|
+
{ dir: 'parts', key: 'partDir', label: 'Block template parts' },
|
|
24
26
|
];
|
|
25
27
|
export default class Init extends Command {
|
|
26
28
|
static description = 'Initialize a loopress.json config file in the current directory';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { LoopressCommand } from '../../lib/base.js';
|
|
3
|
+
import { pinVersion } from '../../types/plugin.js';
|
|
3
4
|
import { writeLocalConfig } from '../../utils/loopress-config.js';
|
|
4
5
|
import { isExactVersion } from '../../utils/version.js';
|
|
5
6
|
export default class Add extends LoopressCommand {
|
|
@@ -25,18 +26,20 @@ export default class Add extends LoopressCommand {
|
|
|
25
26
|
this.error(`--version must be an exact version like 9.4.2, not a Composer constraint. Got "${version}".`);
|
|
26
27
|
}
|
|
27
28
|
const existing = this.localConfig.plugins ?? {};
|
|
28
|
-
|
|
29
|
+
const current = existing[slug];
|
|
30
|
+
if (current !== undefined && pinVersion(current) === version) {
|
|
29
31
|
this.log(`${slug} is already pinned to ${version} in loopress.json, nothing to do.`);
|
|
30
32
|
return { slug, status: 'unchanged', version };
|
|
31
33
|
}
|
|
32
|
-
const isUpdated =
|
|
34
|
+
const isUpdated = current !== undefined;
|
|
33
35
|
if (this.dryRun) {
|
|
34
36
|
this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version}) in loopress.json`);
|
|
35
37
|
return { slug, status: 'dry-run', version };
|
|
36
38
|
}
|
|
37
39
|
await writeLocalConfig({
|
|
38
40
|
...this.localConfig,
|
|
39
|
-
|
|
41
|
+
// Re-pinning a plugin recorded as inactive keeps it inactive.
|
|
42
|
+
plugins: { ...existing, [slug]: typeof current === 'object' ? { ...current, version } : version },
|
|
40
43
|
});
|
|
41
44
|
this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version}). Run \`lps plugin push\` to apply.`);
|
|
42
45
|
return { slug, status: isUpdated ? 'updated' : 'added', version };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import got from 'got';
|
|
2
2
|
import { LoopressCommand } from '../../lib/base.js';
|
|
3
|
+
import { pinVersion } from '../../types/plugin.js';
|
|
3
4
|
import { compareVersions } from '../../utils/version.js';
|
|
4
5
|
const WPVULN_BASE = 'https://www.wpvulnerability.net/plugin';
|
|
5
6
|
const WPORG_INFO = 'https://api.wordpress.org/plugins/info/1.2/';
|
|
@@ -18,7 +19,7 @@ export default class Audit extends LoopressCommand {
|
|
|
18
19
|
const advisories = [];
|
|
19
20
|
const health = [];
|
|
20
21
|
for (const slug of slugs) {
|
|
21
|
-
const found = await this.auditOne(slug, manifest[slug]);
|
|
22
|
+
const found = await this.auditOne(slug, pinVersion(manifest[slug]));
|
|
22
23
|
advisories.push(...found.advisories);
|
|
23
24
|
health.push(...found.health);
|
|
24
25
|
}
|
|
@@ -26,16 +27,14 @@ export default class Audit extends LoopressCommand {
|
|
|
26
27
|
}
|
|
27
28
|
async auditOne(slug, pinned) {
|
|
28
29
|
const [vulns, info] = await Promise.all([this.fetchVulnerabilities(slug), this.fetchHealth(slug)]);
|
|
29
|
-
const advisories = vulns
|
|
30
|
-
.filter((v) => pinned === 'latest' || affects(pinned, v.fixedIn))
|
|
31
|
-
.map((v) => ({ ...v, slug }));
|
|
30
|
+
const advisories = vulns.filter((v) => pinned === 'latest' || affects(pinned, v.fixedIn)).map((v) => ({ ...v, slug }));
|
|
32
31
|
const health = info ? describeHealth(slug, pinned, info) : [];
|
|
33
32
|
return { advisories, health };
|
|
34
33
|
}
|
|
35
34
|
async fetchHealth(slug) {
|
|
36
35
|
try {
|
|
37
36
|
const body = await got(WPORG_INFO, {
|
|
38
|
-
searchParams: {
|
|
37
|
+
searchParams: { action: 'plugin_information', 'request[slug]': slug },
|
|
39
38
|
timeout: { request: 10_000 },
|
|
40
39
|
}).json();
|
|
41
40
|
return typeof body === 'object' && !('error' in body) ? body : null;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { type ComposerPullResult } from '../../utils/composer.js';
|
|
2
3
|
import { type MergeResult } from '../../utils/plugins.js';
|
|
3
4
|
type PullResult = MergeResult & {
|
|
4
|
-
|
|
5
|
+
skipped?: ComposerPullResult['skipped'];
|
|
6
|
+
status: 'dry-run' | 'success';
|
|
5
7
|
};
|
|
6
8
|
export default class Pull extends LoopressCommand {
|
|
7
9
|
static description: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { LoopressCommand } from '../../lib/base.js';
|
|
4
|
+
import { pullIntoComposerJson } from '../../utils/composer.js';
|
|
4
5
|
import { writeLocalConfig } from '../../utils/loopress-config.js';
|
|
5
6
|
import { mergePluginManifest, parseInstalledPlugins } from '../../utils/plugins.js';
|
|
6
7
|
export default class Pull extends LoopressCommand {
|
|
@@ -12,18 +13,25 @@ export default class Pull extends LoopressCommand {
|
|
|
12
13
|
};
|
|
13
14
|
async run() {
|
|
14
15
|
const { url } = this.siteConfig;
|
|
15
|
-
if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
|
|
16
|
-
this.warn('This project has a composer.json, which is authoritative for plugins. Run `lps composer pull` instead.');
|
|
17
|
-
return { added: [], merged: this.localConfig.plugins ?? {}, status: 'composer-managed', updated: [] };
|
|
18
|
-
}
|
|
19
16
|
this.log(`Pulling plugins from ${url}`);
|
|
20
17
|
const raw = await this.wp.get('wp/v2/plugins');
|
|
21
18
|
const installed = parseInstalledPlugins(raw);
|
|
22
|
-
// Pin every plugin to the version actually running on the site
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
const
|
|
19
|
+
// Pin every plugin to the version actually running on the site, and record the inactive ones
|
|
20
|
+
// as such so a later `plugin push` doesn't switch them on. A later `plugin push` installs
|
|
21
|
+
// exactly this set via Composer + WPackagist; drift only surfaces when the pinned version and
|
|
22
|
+
// the live version disagree.
|
|
23
|
+
const versions = Object.fromEntries(installed.map((p) => [p.slug, p.version]));
|
|
24
|
+
const pins = Object.fromEntries(installed.map((p) => [p.slug, p.active ? p.version : { active: false, version: p.version }]));
|
|
25
|
+
// A composer.json is authoritative over loopress.json: pin the live versions there instead.
|
|
26
|
+
const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
|
|
27
|
+
if (existsSync(composerJsonPath)) {
|
|
28
|
+
const result = await pullIntoComposerJson(composerJsonPath, 'plugin', versions, {
|
|
29
|
+
dryRun: this.dryRun,
|
|
30
|
+
log: this.log.bind(this),
|
|
31
|
+
});
|
|
32
|
+
return { ...result, status: this.dryRun ? 'dry-run' : 'success' };
|
|
33
|
+
}
|
|
34
|
+
const { added, merged, updated } = mergePluginManifest(this.localConfig.plugins ?? {}, pins);
|
|
27
35
|
if (this.dryRun) {
|
|
28
36
|
this.log(`[dry-run] Would write ${Object.keys(merged).length} plugins to loopress.json`);
|
|
29
37
|
if (added.length > 0)
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { PushCommand } from '../../lib/push-command.js';
|
|
2
2
|
type PushResult = {
|
|
3
3
|
activated: string[];
|
|
4
|
+
deactivated: string[];
|
|
4
5
|
installed: string[];
|
|
5
6
|
pinned: string[];
|
|
6
7
|
pruned: string[];
|
|
7
8
|
removed: string[];
|
|
8
|
-
status: '
|
|
9
|
+
status: 'dry-run' | 'in-sync' | 'success';
|
|
9
10
|
};
|
|
10
11
|
export default class Push extends PushCommand {
|
|
11
12
|
static description: string;
|
|
12
13
|
static enableJsonFlag: boolean;
|
|
13
14
|
static examples: string[];
|
|
14
15
|
static flags: {
|
|
16
|
+
activate: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
17
|
force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
18
|
prune: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
19
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
@@ -24,6 +26,8 @@ export default class Push extends PushCommand {
|
|
|
24
26
|
private fetchInstanceLock;
|
|
25
27
|
private filesForNewlyInstalled;
|
|
26
28
|
private guardForce;
|
|
29
|
+
private loadSource;
|
|
30
|
+
private logStillInactive;
|
|
27
31
|
private result;
|
|
28
32
|
private sync;
|
|
29
33
|
}
|
|
@@ -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 {
|