@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
|
@@ -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,16 +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';
|
|
6
10
|
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
7
11
|
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
12
|
+
import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
|
|
8
13
|
export default class Promote extends Command {
|
|
9
14
|
static args = {
|
|
10
15
|
from: Args.string({ description: 'Environment to copy the configuration from', required: true }),
|
|
11
16
|
to: Args.string({ description: 'Environment to copy the configuration to', required: true }),
|
|
12
17
|
};
|
|
13
|
-
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.';
|
|
14
19
|
static enableJsonFlag = true;
|
|
15
20
|
static examples = ['$ lps promote staging production', '$ lps promote production staging --dry-run'];
|
|
16
21
|
static flags = {
|
|
@@ -29,19 +34,55 @@ export default class Promote extends Command {
|
|
|
29
34
|
}
|
|
30
35
|
if (!dryRun)
|
|
31
36
|
await this.confirmPromotion(from.name, to.name, to.url, yes);
|
|
32
|
-
|
|
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';
|
|
33
48
|
try {
|
|
34
|
-
|
|
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)));
|
|
35
59
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
}
|
|
39
69
|
}
|
|
40
|
-
this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
|
|
41
|
-
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', this.delegateArgv(to.name, dryRun)));
|
|
42
70
|
this.log(dryRun ? `\n[dry-run] ${from.name} would be promoted to ${to.name}.` : `\n${from.name} promoted to ${to.name}.`);
|
|
43
71
|
return { from: from.name, status: dryRun ? 'dry-run' : 'promoted', to: to.name };
|
|
44
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
|
+
}
|
|
45
86
|
// Deliberately not `guardProductionPush`: promote overwrites local tracked files for every
|
|
46
87
|
// target, not just production, so it needs its own confirmation regardless of the name. That
|
|
47
88
|
// prompt also calls out production when relevant, making a second production-only guard
|
|
@@ -70,6 +111,26 @@ export default class Promote extends Command {
|
|
|
70
111
|
argv.push('--dry-run');
|
|
71
112
|
return argv;
|
|
72
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
|
+
}
|
|
73
134
|
requireEnvironment(environments, projectName, name) {
|
|
74
135
|
const env = environments[name];
|
|
75
136
|
if (!env) {
|
package/dist/commands/pull.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { LoopressCommand } from '../lib/base.js';
|
|
2
2
|
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
3
3
|
import { pluralize } from '../utils/pluralize.js';
|
|
4
|
-
// composer before plugins: `plugin:pull`
|
|
5
|
-
//
|
|
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
|
|
6
6
|
// directories and their order does not matter, it mirrors `lps push` for familiarity.
|
|
7
7
|
const PULL_TARGETS = [
|
|
8
8
|
{ commandId: 'composer:pull', label: 'composer' },
|
package/dist/commands/push.d.ts
CHANGED
package/dist/commands/push.js
CHANGED
|
@@ -1,6 +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';
|
|
3
6
|
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
7
|
+
import { wpackagistRequire } from '../utils/composer.js';
|
|
4
8
|
import { pluralize } from '../utils/pluralize.js';
|
|
5
9
|
// Dependency-ish order: code first (plugins, composer), then the ACF definitions content
|
|
6
10
|
// relies on, then content itself.
|
|
@@ -11,6 +15,8 @@ const PUSH_TARGETS = [
|
|
|
11
15
|
{ commandId: 'api:push', label: 'API routes' },
|
|
12
16
|
{ commandId: 'hook:push', label: 'hooks' },
|
|
13
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' },
|
|
14
20
|
{ commandId: 'page:push', label: 'pages' },
|
|
15
21
|
{ commandId: 'seo:push', label: 'SEO' },
|
|
16
22
|
{ commandId: 'menu:push', label: 'menus' },
|
|
@@ -18,7 +24,7 @@ const PUSH_TARGETS = [
|
|
|
18
24
|
{ commandId: 'snippet:push', label: 'snippets' },
|
|
19
25
|
];
|
|
20
26
|
export default class Push extends LoopressCommand {
|
|
21
|
-
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';
|
|
22
28
|
static enableJsonFlag = true;
|
|
23
29
|
static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
|
|
24
30
|
static flags = {
|
|
@@ -30,7 +36,13 @@ export default class Push extends LoopressCommand {
|
|
|
30
36
|
await this.guardProductionPush();
|
|
31
37
|
const argv = this.buildArgv();
|
|
32
38
|
const results = [];
|
|
39
|
+
const composerPushedByPlugins = await this.composerDeclaresPlugins();
|
|
33
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
|
+
}
|
|
34
46
|
this.log(`\n→ Pushing ${target.label}...`);
|
|
35
47
|
try {
|
|
36
48
|
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
|
|
@@ -47,7 +59,9 @@ export default class Push extends LoopressCommand {
|
|
|
47
59
|
if (this.failedCount > 0) {
|
|
48
60
|
// Each failure's own reason, not just the count: under --json the per-resource lines above
|
|
49
61
|
// never reach stdout, and this message is all an MCP caller or a script gets to see.
|
|
50
|
-
const failures = results
|
|
62
|
+
const failures = results
|
|
63
|
+
.filter((result) => result.status === 'failed')
|
|
64
|
+
.map((result) => `${result.label}: ${result.error}`);
|
|
51
65
|
this.error(`${pluralize(this.failedCount, 'resource')} failed to push. ${failures.join('; ')}`);
|
|
52
66
|
}
|
|
53
67
|
this.log('\nAll resources pushed.');
|
|
@@ -62,9 +76,29 @@ export default class Push extends LoopressCommand {
|
|
|
62
76
|
argv.push('--dry-run');
|
|
63
77
|
return argv;
|
|
64
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
|
+
}
|
|
65
94
|
// Guards once here (shared with PushCommand, lib/push-command.ts) rather than once per
|
|
66
95
|
// delegated command.
|
|
67
96
|
async guardProductionPush() {
|
|
68
|
-
await guardProductionPush({
|
|
97
|
+
await guardProductionPush({
|
|
98
|
+
dryRun: this.dryRun,
|
|
99
|
+
error: (message) => this.error(message),
|
|
100
|
+
siteConfig: this.siteConfig,
|
|
101
|
+
yes: this.yes,
|
|
102
|
+
});
|
|
69
103
|
}
|
|
70
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 {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
3
|
+
import { CHILD_THEME_ENDPOINT } from '../../utils/template-format.js';
|
|
4
|
+
export default class List extends LoopressCommand {
|
|
5
|
+
static description = 'Show the Loopress child theme of the active block theme: its templates, parts, and those edited in the Site Editor';
|
|
6
|
+
static enableJsonFlag = true;
|
|
7
|
+
static examples = ['$ lps template list'];
|
|
8
|
+
async run() {
|
|
9
|
+
const child = await this.wp.get(CHILD_THEME_ENDPOINT);
|
|
10
|
+
// The markup is only needed by `template diff`, it would drown the JSON output here.
|
|
11
|
+
const result = { ...child, parts: child.parts.map((part) => part.slug), templates: child.templates.map((template) => template.slug) };
|
|
12
|
+
if (!child.exists) {
|
|
13
|
+
this.log(`No Loopress child theme yet (${child.stylesheet}), run lps template push.`);
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
this.log(`${child.stylesheet} (child of ${child.parent}, ${child.active ? 'active' : 'not active'})`);
|
|
17
|
+
this.log(` ${pluralize(result.templates.length, 'template')}: ${result.templates.join(', ') || 'none'}`);
|
|
18
|
+
this.log(` ${pluralize(result.parts.length, 'part')}: ${result.parts.join(', ') || 'none'}`);
|
|
19
|
+
if (child.customized.length > 0)
|
|
20
|
+
this.log(` Edited in the Site Editor: ${child.customized.join(', ')}`);
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
type PushResult = {
|
|
3
|
+
active?: boolean;
|
|
4
|
+
customized?: string[];
|
|
5
|
+
parts: string[];
|
|
6
|
+
status: 'dry-run' | 'success';
|
|
7
|
+
stylesheet?: string;
|
|
8
|
+
templates: string[];
|
|
9
|
+
};
|
|
10
|
+
export default class Push extends PushCommand {
|
|
11
|
+
static description: string;
|
|
12
|
+
static enableJsonFlag: boolean;
|
|
13
|
+
static examples: string[];
|
|
14
|
+
static flags: {
|
|
15
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
};
|
|
18
|
+
run(): Promise<PushResult>;
|
|
19
|
+
}
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
import { formatPageProblems } from '../../utils/page-format.js';
|
|
3
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
4
|
+
import { resolveResourceDir } from '../../utils/resource-dirs.js';
|
|
5
|
+
import { CHILD_THEME_ENDPOINT, readLocalTemplates } from '../../utils/template-format.js';
|
|
6
|
+
export default class Push extends PushCommand {
|
|
7
|
+
static description = 'Push block templates (templates/<slug>.html) and template parts (parts/<slug>.html) as the files of a child theme of the active block theme, <parent>-loopress. The child mirrors the project: files removed locally are removed from it. It is never activated for you.';
|
|
8
|
+
static enableJsonFlag = true;
|
|
9
|
+
static examples = ['$ lps template push', '$ lps template push --dry-run'];
|
|
10
|
+
static flags = {
|
|
11
|
+
...PushCommand.dryRunFlag,
|
|
12
|
+
...PushCommand.yesFlag,
|
|
13
|
+
};
|
|
14
|
+
async run() {
|
|
15
|
+
await this.parse(Push);
|
|
16
|
+
const templatesPath = resolveResourceDir('template', this.localConfig);
|
|
17
|
+
const partsPath = resolveResourceDir('part', this.localConfig);
|
|
18
|
+
this.log(`Pushing templates and parts to ${this.siteConfig.url}`);
|
|
19
|
+
this.log(`Templates path: ${templatesPath}, parts path: ${partsPath}`);
|
|
20
|
+
const { parts, problems, templates } = await readLocalTemplates(templatesPath, partsPath);
|
|
21
|
+
if (problems.length > 0) {
|
|
22
|
+
this.error(`Nothing was pushed, fix these files first:\n ${formatPageProblems(problems)}`);
|
|
23
|
+
}
|
|
24
|
+
const names = { parts: parts.map((part) => part.slug), templates: templates.map((template) => template.slug) };
|
|
25
|
+
this.log(`Found ${pluralize(templates.length, 'template')} and ${pluralize(parts.length, 'part')}`);
|
|
26
|
+
if (this.dryRun) {
|
|
27
|
+
this.log('[dry-run] Would rewrite the child theme with them.');
|
|
28
|
+
return { ...names, status: 'dry-run' };
|
|
29
|
+
}
|
|
30
|
+
const child = await this.wp.put(CHILD_THEME_ENDPOINT, { parts, templates });
|
|
31
|
+
await this.recordSuccess();
|
|
32
|
+
this.log(`Child theme ${child.stylesheet} written.`);
|
|
33
|
+
if (!child.active) {
|
|
34
|
+
this.warn(`${child.stylesheet} is not the active theme, so nothing changed on the site yet. Activate it in Appearance > Themes.`);
|
|
35
|
+
}
|
|
36
|
+
if (child.customized.length > 0) {
|
|
37
|
+
this.warn(`Edited in the Site Editor, their database copy still wins over the pushed file: ${child.customized.join(', ')}. ` +
|
|
38
|
+
'Clear their customizations in Appearance > Editor to use the pushed version.');
|
|
39
|
+
}
|
|
40
|
+
return { ...names, active: child.active, customized: child.customized, status: 'success', stylesheet: child.stylesheet };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { type ComposerPullResult } from '../../utils/composer.js';
|
|
2
3
|
type PullResult = {
|
|
3
4
|
added: string[];
|
|
4
5
|
merged: Record<string, string>;
|
|
5
|
-
|
|
6
|
+
skipped?: ComposerPullResult['skipped'];
|
|
7
|
+
status: 'dry-run' | 'success';
|
|
6
8
|
updated: Array<{
|
|
7
9
|
from: string;
|
|
8
10
|
slug: 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 } from '../../utils/plugins.js';
|
|
6
7
|
import { parseInstalledThemes } from '../../utils/themes.js';
|
|
@@ -13,14 +14,19 @@ export default class Pull extends LoopressCommand {
|
|
|
13
14
|
};
|
|
14
15
|
async run() {
|
|
15
16
|
const { url } = this.siteConfig;
|
|
16
|
-
if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
|
|
17
|
-
this.warn('This project has a composer.json, which is authoritative. Run `lps composer pull` instead.');
|
|
18
|
-
return { added: [], merged: this.localConfig.themes ?? {}, status: 'composer-managed', updated: [] };
|
|
19
|
-
}
|
|
20
17
|
this.log(`Pulling themes from ${url}`);
|
|
21
18
|
const raw = await this.wp.get('wp/v2/themes');
|
|
22
19
|
const installed = parseInstalledThemes(raw);
|
|
23
20
|
const incoming = Object.fromEntries(installed.map((t) => [t.slug, t.version]));
|
|
21
|
+
// A composer.json is authoritative over loopress.json: pin the live versions there instead.
|
|
22
|
+
const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
|
|
23
|
+
if (existsSync(composerJsonPath)) {
|
|
24
|
+
const result = await pullIntoComposerJson(composerJsonPath, 'theme', incoming, {
|
|
25
|
+
dryRun: this.dryRun,
|
|
26
|
+
log: this.log.bind(this),
|
|
27
|
+
});
|
|
28
|
+
return { ...result, status: this.dryRun ? 'dry-run' : 'success' };
|
|
29
|
+
}
|
|
24
30
|
const { added, merged, updated } = mergePluginManifest(this.localConfig.themes ?? {}, incoming);
|
|
25
31
|
if (this.dryRun) {
|
|
26
32
|
this.log(`[dry-run] Would write ${Object.keys(merged).length} themes to loopress.json`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { resourceDiffCommand } from '
|
|
1
|
+
import { resourceDiffCommand } from '../../../lib/diff-command.js';
|
|
2
2
|
export default resourceDiffCommand('theme-styles', {
|
|
3
3
|
description: "Show what differs in the active block theme's Global Styles (Site Editor > Styles) between your local file and a WordPress environment, or between two environments",
|
|
4
|
+
command: 'theme style',
|
|
4
5
|
pathNoun: 'theme styles directory',
|
|
5
6
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LoopressCommand } from '
|
|
1
|
+
import { LoopressCommand } from '../../../lib/base.js';
|
|
2
2
|
export default class Pull extends LoopressCommand {
|
|
3
3
|
static args: {
|
|
4
4
|
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { LoopressCommand } from '
|
|
5
|
-
import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName, } from '
|
|
4
|
+
import { LoopressCommand } from '../../../lib/base.js';
|
|
5
|
+
import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName, } from '../../../utils/theme-styles-format.js';
|
|
6
6
|
export default class Pull extends LoopressCommand {
|
|
7
7
|
static args = {
|
|
8
8
|
path: Args.string({ description: 'Path to theme styles directory (overrides project config)' }),
|
|
9
9
|
};
|
|
10
10
|
static description = "Pull the active block theme's Global Styles customizations (Site Editor > Styles) from WordPress into a local file";
|
|
11
|
-
static examples = ['$ lps theme
|
|
11
|
+
static examples = ['$ lps theme style pull'];
|
|
12
12
|
static flags = {
|
|
13
13
|
...LoopressCommand.dryRunFlag,
|
|
14
14
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PushCommand } from '
|
|
1
|
+
import { PushCommand } from '../../../lib/push-command.js';
|
|
2
2
|
export default class Push extends PushCommand {
|
|
3
3
|
static args: {
|
|
4
4
|
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { PushCommand } from '
|
|
5
|
-
import { getResourceStateProvider } from '
|
|
6
|
-
import { getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName } from '
|
|
4
|
+
import { PushCommand } from '../../../lib/push-command.js';
|
|
5
|
+
import { getResourceStateProvider } from '../../../lib/resource-state.js';
|
|
6
|
+
import { getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName } from '../../../utils/theme-styles-format.js';
|
|
7
7
|
export default class Push extends PushCommand {
|
|
8
8
|
static args = {
|
|
9
9
|
path: Args.string({ description: 'Path to theme styles directory (overrides project config)' }),
|
|
10
10
|
};
|
|
11
11
|
static description = "Push the local Global Styles file to the active block theme's Site Editor > Styles on WordPress";
|
|
12
|
-
static examples = ['$ lps theme
|
|
12
|
+
static examples = ['$ lps theme style push'];
|
|
13
13
|
static flags = {
|
|
14
14
|
...PushCommand.dryRunFlag,
|
|
15
15
|
};
|
|
@@ -26,7 +26,7 @@ export default class Push extends PushCommand {
|
|
|
26
26
|
}
|
|
27
27
|
catch (error) {
|
|
28
28
|
if (error.code === 'ENOENT') {
|
|
29
|
-
this.error(`No local Global Styles file found for the active theme ("${file}"). Run \`lps theme
|
|
29
|
+
this.error(`No local Global Styles file found for the active theme ("${file}"). Run \`lps theme style pull\` first.`);
|
|
30
30
|
}
|
|
31
31
|
throw error;
|
|
32
32
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { resourceRollbackCommand } from '../../../lib/rollback-command.js';
|
|
2
|
+
export default resourceRollbackCommand('theme-styles', {
|
|
3
|
+
description: "Restore the active theme's Global Styles to a snapshot taken automatically before an earlier `lps theme style push`",
|
|
4
|
+
command: 'theme style',
|
|
5
|
+
pathNoun: 'theme styles directory',
|
|
6
|
+
});
|
package/dist/lib/api-client.js
CHANGED
|
@@ -45,7 +45,9 @@ function formatApiError(error, url, timeoutMs) {
|
|
|
45
45
|
}
|
|
46
46
|
const reason = nestErrorMessage(err.response?.body);
|
|
47
47
|
if (status === 403) {
|
|
48
|
-
|
|
48
|
+
// An empty body (not just a missing one) falls back to got's message too, never a bare ": ".
|
|
49
|
+
const body = err.response?.body === '' ? undefined : err.response?.body;
|
|
50
|
+
return `Request rejected (${status}) on ${url}: ${reason ?? body ?? err.message}`;
|
|
49
51
|
}
|
|
50
52
|
// Any other API refusal (a 400 validation error, a 409 conflict) carries its explanation in
|
|
51
53
|
// the body; got's own message ("Request failed with status code 400") never does.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
declare const PACKAGE_MANAGERS: readonly ["bun", "npm", "pnpm", "yarn"];
|
|
2
|
+
type PackageManager = (typeof PACKAGE_MANAGERS)[number];
|
|
3
|
+
export declare function detectPackageManager(appDir: string, packageManagerField?: string): PackageManager;
|
|
4
|
+
export declare function buildApp(appDir: string, onStart?: (command: string) => void): Promise<string | undefined>;
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
// The package managers `lps app push` may spawn. An allowlist, not the raw `packageManager`
|
|
6
|
+
// field: the name ends up as a process to run.
|
|
7
|
+
const PACKAGE_MANAGERS = ['bun', 'npm', 'pnpm', 'yarn'];
|
|
8
|
+
const LOCKFILES = [
|
|
9
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
10
|
+
['yarn.lock', 'yarn'],
|
|
11
|
+
['bun.lock', 'bun'],
|
|
12
|
+
['bun.lockb', 'bun'],
|
|
13
|
+
['package-lock.json', 'npm'],
|
|
14
|
+
];
|
|
15
|
+
// How many trailing lines of build output a failure message carries.
|
|
16
|
+
const OUTPUT_TAIL_LINES = 20;
|
|
17
|
+
// `packageManager` field first (corepack's own source of truth), then the nearest lockfile
|
|
18
|
+
// walking up to the repository root (an app inside a workspace shares the root lockfile),
|
|
19
|
+
// then npm.
|
|
20
|
+
export function detectPackageManager(appDir, packageManagerField) {
|
|
21
|
+
const fromField = packageManagerField?.split('@', 1)[0];
|
|
22
|
+
if (PACKAGE_MANAGERS.includes(fromField))
|
|
23
|
+
return fromField;
|
|
24
|
+
for (let dir = appDir;; dir = dirname(dir)) {
|
|
25
|
+
const lockfile = LOCKFILES.find(([file]) => existsSync(join(dir, file)));
|
|
26
|
+
if (lockfile)
|
|
27
|
+
return lockfile[1];
|
|
28
|
+
if (existsSync(join(dir, '.git')) || dirname(dir) === dir)
|
|
29
|
+
return 'npm';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Runs the app's own `build` script, if its package.json declares one. Returns the command it
|
|
33
|
+
// ran, or undefined when there is nothing to build (no package.json, or no build script: the
|
|
34
|
+
// dist/ folder is then taken as is). Throws with the tail of the output when the build fails,
|
|
35
|
+
// so a broken build never ships the stale dist/ left over from the previous one.
|
|
36
|
+
export async function buildApp(appDir, onStart) {
|
|
37
|
+
const packageJsonPath = join(appDir, 'package.json');
|
|
38
|
+
if (!existsSync(packageJsonPath))
|
|
39
|
+
return undefined;
|
|
40
|
+
const pkg = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
|
41
|
+
if (!pkg.scripts?.build)
|
|
42
|
+
return undefined;
|
|
43
|
+
const pm = detectPackageManager(appDir, pkg.packageManager);
|
|
44
|
+
const command = `${pm} run build`;
|
|
45
|
+
onStart?.(command);
|
|
46
|
+
const { code, output } = await new Promise((resolve, reject) => {
|
|
47
|
+
// Windows resolves `npm` to `npm.cmd`, which only a shell finds. `pm` comes from the
|
|
48
|
+
// allowlist above, so the shell never sees user input.
|
|
49
|
+
const child = spawn(pm, ['run', 'build'], { cwd: appDir, shell: process.platform === 'win32' });
|
|
50
|
+
let out = '';
|
|
51
|
+
const collect = (chunk) => {
|
|
52
|
+
out += chunk;
|
|
53
|
+
};
|
|
54
|
+
child.stdout.setEncoding('utf8').on('data', collect);
|
|
55
|
+
child.stderr.setEncoding('utf8').on('data', collect);
|
|
56
|
+
child.on('error', reject);
|
|
57
|
+
child.on('close', (exitCode) => {
|
|
58
|
+
resolve({ code: exitCode, output: out });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
if (code !== 0) {
|
|
62
|
+
const tail = output.trimEnd().split('\n').slice(-OUTPUT_TAIL_LINES).join('\n');
|
|
63
|
+
const hint = existsSync(join(appDir, 'node_modules'))
|
|
64
|
+
? ''
|
|
65
|
+
: `\nnode_modules is missing: run \`${pm} install\` in ${appDir} first.`;
|
|
66
|
+
throw new Error(`\`${command}\` failed (exit code ${code}):\n${tail}${hint}`);
|
|
67
|
+
}
|
|
68
|
+
return command;
|
|
69
|
+
}
|