@loopress/cli 0.25.0 → 0.27.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 +197 -101
- package/dist/commands/diff.js +1 -1
- package/dist/commands/init.js +6 -1
- package/dist/commands/page/diff.d.ts +2 -0
- package/dist/commands/page/diff.js +5 -0
- package/dist/commands/page/list.d.ts +8 -0
- package/dist/commands/page/list.js +22 -0
- package/dist/commands/page/push.d.ts +20 -0
- package/dist/commands/page/push.js +60 -0
- package/dist/commands/plugin/add.d.ts +8 -1
- package/dist/commands/plugin/add.js +4 -2
- package/dist/commands/promote.js +3 -2
- package/dist/commands/pull.js +6 -2
- package/dist/commands/push.js +8 -3
- package/dist/commands/theme/add.d.ts +8 -1
- package/dist/commands/theme/add.js +4 -2
- package/dist/lib/api-client.js +24 -1
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +3 -1
- 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 +24 -0
- package/dist/lib/snapshot-store.js +14 -0
- package/dist/lib/validate-local.js +33 -7
- package/dist/lib/wp-client.js +31 -2
- package/dist/types/project-config.generated.d.ts +4 -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 +31 -0
- package/dist/utils/page-format.js +118 -0
- package/dist/utils/resource-dirs.d.ts +1 -0
- package/dist/utils/resource-dirs.js +2 -0
- package/oclif.manifest.json +506 -298
- package/package.json +5 -3
- package/schemas/project-config.schema.json +5 -0
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, 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, 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
|
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { configManager } from '../config/project-config.manager.js';
|
|
6
6
|
import { isInteractive } from '../lib/interactive.js';
|
|
7
|
-
import { writeLocalConfig } from '../utils/loopress-config.js';
|
|
7
|
+
import { ensureLfGitattributes, writeLocalConfig } from '../utils/loopress-config.js';
|
|
8
8
|
// WordPress.org slugs for the two snippet plugins the Loopress WordPress plugin supports
|
|
9
9
|
// (see SnippetModule.php, which wires up both providers and auto-detects the active one).
|
|
10
10
|
const SNIPPET_PROVIDERS = [
|
|
@@ -20,6 +20,7 @@ const FEATURES = [
|
|
|
20
20
|
{ dir: 'api', key: 'apiDir', label: 'Custom API routes' },
|
|
21
21
|
{ dir: 'hooks', key: 'hooksDir', label: 'Hooks (actions, filters, cron)' },
|
|
22
22
|
{ dir: 'apps', key: 'appsDir', label: 'Single-page apps' },
|
|
23
|
+
{ dir: 'pages', key: 'pageDir', label: 'Static pages' },
|
|
23
24
|
];
|
|
24
25
|
export default class Init extends Command {
|
|
25
26
|
static description = 'Initialize a loopress.json config file in the current directory';
|
|
@@ -67,6 +68,7 @@ export default class Init extends Command {
|
|
|
67
68
|
config[feature.key] = feature.dir;
|
|
68
69
|
}
|
|
69
70
|
await writeLocalConfig(config);
|
|
71
|
+
const isGitattributesUpdated = await ensureLfGitattributes();
|
|
70
72
|
let isProviderAdded = false;
|
|
71
73
|
if (providerChoice !== '__none__') {
|
|
72
74
|
try {
|
|
@@ -88,6 +90,9 @@ export default class Init extends Command {
|
|
|
88
90
|
if (isProviderAdded) {
|
|
89
91
|
this.log(` Plugin: ${providerChoice}`);
|
|
90
92
|
}
|
|
93
|
+
if (isGitattributesUpdated) {
|
|
94
|
+
this.log('✓ .gitattributes: LF line endings, so files match across macOS, Linux and Windows');
|
|
95
|
+
}
|
|
91
96
|
this.log('\n→ Next: run `lps snippet pull` to fetch what is already on the site, or `lps doctor` to verify the connection.');
|
|
92
97
|
}
|
|
93
98
|
// When nothing is configured yet, the useful path is configuring a project right here, not
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { resourceDiffCommand } from '../../lib/diff-command.js';
|
|
2
|
+
export default resourceDiffCommand('page', {
|
|
3
|
+
description: 'Show what differs in static pages between your local files and a WordPress environment, or between two environments',
|
|
4
|
+
pathNoun: 'pages directory',
|
|
5
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { type RemotePage } from '../../utils/page-format.js';
|
|
3
|
+
export default class List extends LoopressCommand {
|
|
4
|
+
static description: string;
|
|
5
|
+
static enableJsonFlag: boolean;
|
|
6
|
+
static examples: string[];
|
|
7
|
+
run(): Promise<Array<Omit<RemotePage, 'html'>>>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { PAGES_ENDPOINT } from '../../utils/page-format.js';
|
|
3
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
4
|
+
export default class List extends LoopressCommand {
|
|
5
|
+
static description = 'List the static pages managed by Loopress on WordPress';
|
|
6
|
+
static enableJsonFlag = true;
|
|
7
|
+
static examples = ['$ lps page list'];
|
|
8
|
+
async run() {
|
|
9
|
+
const pages = await this.wp.get(PAGES_ENDPOINT);
|
|
10
|
+
// The HTML is only needed by `page diff`, it would drown the JSON output here.
|
|
11
|
+
const listed = pages.map(({ html: _html, ...page }) => page);
|
|
12
|
+
if (listed.length === 0) {
|
|
13
|
+
this.log('No pages managed by Loopress');
|
|
14
|
+
return listed;
|
|
15
|
+
}
|
|
16
|
+
this.log(`Found ${pluralize(listed.length, 'page')}:`);
|
|
17
|
+
this.log('');
|
|
18
|
+
for (const page of listed)
|
|
19
|
+
this.log(` ${page.slug} ${page.status} ${page.link}`);
|
|
20
|
+
return listed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
type PushResult = {
|
|
3
|
+
pushed: string[];
|
|
4
|
+
status: 'dry-run' | 'success';
|
|
5
|
+
};
|
|
6
|
+
export default class Push extends PushCommand {
|
|
7
|
+
static args: {
|
|
8
|
+
slug: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
9
|
+
};
|
|
10
|
+
static description: string;
|
|
11
|
+
static enableJsonFlag: boolean;
|
|
12
|
+
static examples: string[];
|
|
13
|
+
static flags: {
|
|
14
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
};
|
|
17
|
+
run(): Promise<PushResult>;
|
|
18
|
+
private pushPage;
|
|
19
|
+
}
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
3
|
+
import { formatPageProblems, frontPageNote, PAGES_ENDPOINT, readLocalPages } from '../../utils/page-format.js';
|
|
4
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
5
|
+
import { resolveResourceDir } from '../../utils/resource-dirs.js';
|
|
6
|
+
export default class Push extends PushCommand {
|
|
7
|
+
static args = {
|
|
8
|
+
slug: Args.string({ description: 'Push only this page (its file name without .html). Pushes every page when omitted.' }),
|
|
9
|
+
};
|
|
10
|
+
static description = 'Push static HTML pages (pages/<slug>.html) to WordPress. The `status` header (draft or publish, draft by default) is applied on every push.';
|
|
11
|
+
static enableJsonFlag = true;
|
|
12
|
+
static examples = ['$ lps page push', '$ lps page push legal-notice', '$ lps page push --dry-run'];
|
|
13
|
+
static flags = {
|
|
14
|
+
...PushCommand.dryRunFlag,
|
|
15
|
+
...PushCommand.yesFlag,
|
|
16
|
+
};
|
|
17
|
+
async run() {
|
|
18
|
+
const { args } = await this.parse(Push);
|
|
19
|
+
const path = resolveResourceDir('page', this.localConfig);
|
|
20
|
+
this.log(`Pushing pages to ${this.siteConfig.url}`);
|
|
21
|
+
this.log(`Pages path: ${path}`);
|
|
22
|
+
const { pages, problems } = await readLocalPages(path);
|
|
23
|
+
if (problems.length > 0) {
|
|
24
|
+
this.error(`Nothing was pushed, fix these pages first:\n ${formatPageProblems(problems)}`);
|
|
25
|
+
}
|
|
26
|
+
const selected = args.slug === undefined ? pages : pages.filter((page) => page.slug === args.slug);
|
|
27
|
+
if (args.slug !== undefined && selected.length === 0) {
|
|
28
|
+
this.error(`No page "${args.slug}" in ${path} (expected ${args.slug}.html).`);
|
|
29
|
+
}
|
|
30
|
+
this.log(`Found ${pluralize(selected.length, 'page')} to push`);
|
|
31
|
+
const pushed = [];
|
|
32
|
+
await this.runPushTasks(selected, (page) => page.slug, async (page, task) => {
|
|
33
|
+
await this.pushPage(page, task);
|
|
34
|
+
pushed.push(page.slug);
|
|
35
|
+
});
|
|
36
|
+
if (this.failedCount > 0) {
|
|
37
|
+
this.error(`${pluralize(this.failedCount, 'page')} failed to push.`);
|
|
38
|
+
}
|
|
39
|
+
if (this.dryRun)
|
|
40
|
+
return { pushed, status: 'dry-run' };
|
|
41
|
+
await this.recordSuccess();
|
|
42
|
+
this.log('All pages pushed.');
|
|
43
|
+
return { pushed, status: 'success' };
|
|
44
|
+
}
|
|
45
|
+
async pushPage(page, task) {
|
|
46
|
+
if (this.dryRun) {
|
|
47
|
+
if (task)
|
|
48
|
+
task.output = `[dry-run] Would push: ${page.slug} (${page.status})`;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const result = await this.wp.put(PAGES_ENDPOINT, page);
|
|
53
|
+
if (task)
|
|
54
|
+
task.output = `Pushed: ${page.slug} (${result.status}) ${result.link}${frontPageNote(result)}`;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
this.reportTaskFailure(`Failed to push ${page.slug}: ${error.message}`, error, task);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
type AddResult = {
|
|
3
|
+
slug: string;
|
|
4
|
+
status: 'added' | 'dry-run' | 'unchanged' | 'updated';
|
|
5
|
+
version: string;
|
|
6
|
+
};
|
|
2
7
|
export default class Add extends LoopressCommand {
|
|
3
8
|
static args: {
|
|
4
9
|
slug: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
10
|
};
|
|
6
11
|
static description: string;
|
|
12
|
+
static enableJsonFlag: boolean;
|
|
7
13
|
static examples: string[];
|
|
8
14
|
static flags: {
|
|
9
15
|
version: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
16
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
17
|
};
|
|
12
|
-
run(): Promise<
|
|
18
|
+
run(): Promise<AddResult>;
|
|
13
19
|
}
|
|
20
|
+
export {};
|
|
@@ -7,6 +7,7 @@ export default class Add extends LoopressCommand {
|
|
|
7
7
|
slug: Args.string({ description: 'Plugin slug on WordPress.org', required: true }),
|
|
8
8
|
};
|
|
9
9
|
static description = 'Add a WordPress.org plugin to loopress.json';
|
|
10
|
+
static enableJsonFlag = true;
|
|
10
11
|
static examples = [
|
|
11
12
|
'$ lps plugin add woocommerce',
|
|
12
13
|
'$ lps plugin add woocommerce --version 9.4.2',
|
|
@@ -26,17 +27,18 @@ export default class Add extends LoopressCommand {
|
|
|
26
27
|
const existing = this.localConfig.plugins ?? {};
|
|
27
28
|
if (existing[slug] === version) {
|
|
28
29
|
this.log(`${slug} is already pinned to ${version} in loopress.json, nothing to do.`);
|
|
29
|
-
return;
|
|
30
|
+
return { slug, status: 'unchanged', version };
|
|
30
31
|
}
|
|
31
32
|
const isUpdated = existing[slug] !== undefined;
|
|
32
33
|
if (this.dryRun) {
|
|
33
34
|
this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version}) in loopress.json`);
|
|
34
|
-
return;
|
|
35
|
+
return { slug, status: 'dry-run', version };
|
|
35
36
|
}
|
|
36
37
|
await writeLocalConfig({
|
|
37
38
|
...this.localConfig,
|
|
38
39
|
plugins: { ...existing, [slug]: version },
|
|
39
40
|
});
|
|
40
41
|
this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version}). Run \`lps plugin push\` to apply.`);
|
|
42
|
+
return { slug, status: isUpdated ? 'updated' : 'added', version };
|
|
41
43
|
}
|
|
42
44
|
}
|
package/dist/commands/promote.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Args, Command } from '@oclif/core';
|
|
|
3
3
|
import { configManager } from '../config/project-config.manager.js';
|
|
4
4
|
import { LoopressCommand } from '../lib/base.js';
|
|
5
5
|
import { isInteractive } from '../lib/interactive.js';
|
|
6
|
+
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
6
7
|
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
7
8
|
export default class Promote extends Command {
|
|
8
9
|
static args = {
|
|
@@ -30,14 +31,14 @@ export default class Promote extends Command {
|
|
|
30
31
|
await this.confirmPromotion(from.name, to.name, to.url, yes);
|
|
31
32
|
this.log(`\n=== Pulling from ${from.name} (${from.url}) ===`);
|
|
32
33
|
try {
|
|
33
|
-
await this.config.runCommand('pull', this.delegateArgv(from.name, dryRun));
|
|
34
|
+
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, dryRun)));
|
|
34
35
|
}
|
|
35
36
|
catch (error) {
|
|
36
37
|
// A partial pull must never be pushed onward: stop before touching <to>.
|
|
37
38
|
this.error(`Pull from ${from.name} failed, ${to.name} left untouched: ${error.message}`);
|
|
38
39
|
}
|
|
39
40
|
this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
|
|
40
|
-
await this.config.runCommand('push', this.delegateArgv(to.name, dryRun));
|
|
41
|
+
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', this.delegateArgv(to.name, dryRun)));
|
|
41
42
|
this.log(dryRun ? `\n[dry-run] ${from.name} would be promoted to ${to.name}.` : `\n${from.name} promoted to ${to.name}.`);
|
|
42
43
|
return { from: from.name, status: dryRun ? 'dry-run' : 'promoted', to: to.name };
|
|
43
44
|
}
|
package/dist/commands/pull.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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
4
|
// composer before plugins: `plugin:pull` reads the local composer.json to skip
|
|
4
5
|
// Composer-managed plugins, so it needs the freshly pulled one. The rest write to independent
|
|
@@ -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.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LoopressCommand } from '../lib/base.js';
|
|
2
2
|
import { guardProductionPush } from '../lib/guard-production-push.js';
|
|
3
|
+
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
3
4
|
import { pluralize } from '../utils/pluralize.js';
|
|
4
5
|
// Dependency-ish order: code first (plugins, composer), then the ACF definitions content
|
|
5
6
|
// relies on, then content itself.
|
|
@@ -10,13 +11,14 @@ const PUSH_TARGETS = [
|
|
|
10
11
|
{ commandId: 'api:push', label: 'API routes' },
|
|
11
12
|
{ commandId: 'hook:push', label: 'hooks' },
|
|
12
13
|
{ commandId: 'form:push', label: 'forms' },
|
|
14
|
+
{ commandId: 'page:push', label: 'pages' },
|
|
13
15
|
{ commandId: 'seo:push', label: 'SEO' },
|
|
14
16
|
{ commandId: 'menu:push', label: 'menus' },
|
|
15
17
|
{ commandId: 'option:push', label: 'options' },
|
|
16
18
|
{ commandId: 'snippet:push', label: 'snippets' },
|
|
17
19
|
];
|
|
18
20
|
export default class Push extends LoopressCommand {
|
|
19
|
-
static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, SEO, menus, options, and snippets, to WordPress';
|
|
21
|
+
static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, pages, SEO, menus, options, and snippets, to WordPress';
|
|
20
22
|
static enableJsonFlag = true;
|
|
21
23
|
static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
|
|
22
24
|
static flags = {
|
|
@@ -31,7 +33,7 @@ export default class Push extends LoopressCommand {
|
|
|
31
33
|
for (const target of PUSH_TARGETS) {
|
|
32
34
|
this.log(`\n→ Pushing ${target.label}...`);
|
|
33
35
|
try {
|
|
34
|
-
await this.config.runCommand(target.commandId, argv);
|
|
36
|
+
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
|
|
35
37
|
this.log(`✓ ${target.label} pushed`);
|
|
36
38
|
results.push({ label: target.label, status: 'pushed' });
|
|
37
39
|
}
|
|
@@ -43,7 +45,10 @@ export default class Push extends LoopressCommand {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
if (this.failedCount > 0) {
|
|
46
|
-
|
|
48
|
+
// Each failure's own reason, not just the count: under --json the per-resource lines above
|
|
49
|
+
// never reach stdout, and this message is all an MCP caller or a script gets to see.
|
|
50
|
+
const failures = results.filter((result) => result.status === 'failed').map((result) => `${result.label}: ${result.error}`);
|
|
51
|
+
this.error(`${pluralize(this.failedCount, 'resource')} failed to push. ${failures.join('; ')}`);
|
|
47
52
|
}
|
|
48
53
|
this.log('\nAll resources pushed.');
|
|
49
54
|
return { results };
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
type AddResult = {
|
|
3
|
+
slug: string;
|
|
4
|
+
status: 'added' | 'dry-run' | 'unchanged' | 'updated';
|
|
5
|
+
version: string;
|
|
6
|
+
};
|
|
2
7
|
export default class Add extends LoopressCommand {
|
|
3
8
|
static args: {
|
|
4
9
|
slug: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
10
|
};
|
|
6
11
|
static description: string;
|
|
12
|
+
static enableJsonFlag: boolean;
|
|
7
13
|
static examples: string[];
|
|
8
14
|
static flags: {
|
|
9
15
|
version: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
16
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
17
|
};
|
|
12
|
-
run(): Promise<
|
|
18
|
+
run(): Promise<AddResult>;
|
|
13
19
|
}
|
|
20
|
+
export {};
|
|
@@ -7,6 +7,7 @@ export default class Add extends LoopressCommand {
|
|
|
7
7
|
slug: Args.string({ description: 'Theme slug on WordPress.org', required: true }),
|
|
8
8
|
};
|
|
9
9
|
static description = 'Add a WordPress.org theme to loopress.json';
|
|
10
|
+
static enableJsonFlag = true;
|
|
10
11
|
static examples = ['$ lps theme add generatepress', '$ lps theme add generatepress --version 3.4.0'];
|
|
11
12
|
static flags = {
|
|
12
13
|
...LoopressCommand.dryRunFlag,
|
|
@@ -22,14 +23,15 @@ export default class Add extends LoopressCommand {
|
|
|
22
23
|
const existing = this.localConfig.themes ?? {};
|
|
23
24
|
if (existing[slug] === version) {
|
|
24
25
|
this.log(`${slug} is already pinned to ${version}, nothing to do.`);
|
|
25
|
-
return;
|
|
26
|
+
return { slug, status: 'unchanged', version };
|
|
26
27
|
}
|
|
27
28
|
const isUpdated = existing[slug] !== undefined;
|
|
28
29
|
if (this.dryRun) {
|
|
29
30
|
this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version})`);
|
|
30
|
-
return;
|
|
31
|
+
return { slug, status: 'dry-run', version };
|
|
31
32
|
}
|
|
32
33
|
await writeLocalConfig({ ...this.localConfig, themes: { ...existing, [slug]: version } });
|
|
33
34
|
this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version}). Run \`lps theme push\` to apply.`);
|
|
35
|
+
return { slug, status: isUpdated ? 'updated' : 'added', version };
|
|
34
36
|
}
|
|
35
37
|
}
|
package/dist/lib/api-client.js
CHANGED
|
@@ -43,11 +43,34 @@ function formatApiError(error, url, timeoutMs) {
|
|
|
43
43
|
if (status === 401) {
|
|
44
44
|
return `Not logged in or session expired on ${url}. Run \`lps login\` again.`;
|
|
45
45
|
}
|
|
46
|
+
const reason = nestErrorMessage(err.response?.body);
|
|
46
47
|
if (status === 403) {
|
|
47
|
-
return `Request rejected (${status}) on ${url}: ${err.response?.body ?? err.message}`;
|
|
48
|
+
return `Request rejected (${status}) on ${url}: ${reason ?? err.response?.body ?? err.message}`;
|
|
49
|
+
}
|
|
50
|
+
// Any other API refusal (a 400 validation error, a 409 conflict) carries its explanation in
|
|
51
|
+
// the body; got's own message ("Request failed with status code 400") never does.
|
|
52
|
+
if (status !== undefined && reason) {
|
|
53
|
+
return `Request failed (${status}) on ${url}: ${reason}`;
|
|
48
54
|
}
|
|
49
55
|
if (err.name === 'TimeoutError') {
|
|
50
56
|
return `Request timed out after ${timeoutMs / 1000}s on ${url}. Check your network connection, or the Loopress API may be temporarily unreachable.`;
|
|
51
57
|
}
|
|
52
58
|
return err.message ?? String(error);
|
|
53
59
|
}
|
|
60
|
+
// NestJS's HttpException body: `{statusCode, message, error}`, where `message` is a list for a
|
|
61
|
+
// ValidationPipe failure (one entry per invalid field).
|
|
62
|
+
function nestErrorMessage(body) {
|
|
63
|
+
if (!body)
|
|
64
|
+
return undefined;
|
|
65
|
+
try {
|
|
66
|
+
const { message } = JSON.parse(body);
|
|
67
|
+
if (typeof message === 'string' && message.trim())
|
|
68
|
+
return message;
|
|
69
|
+
if (Array.isArray(message) && message.length > 0)
|
|
70
|
+
return message.join('; ');
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// not JSON (a proxy's HTML error page): nothing to extract
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
package/dist/lib/dev-watch.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type LoopressLocalConfig } from '../utils/loopress-config.js';
|
|
2
|
-
export type ResourceType = 'api' | 'plugins' | 'snippets';
|
|
2
|
+
export type ResourceType = 'api' | 'hooks' | 'pages' | 'plugins' | 'snippets';
|
|
3
3
|
export declare const RESOURCE_TYPES: ResourceType[];
|
|
4
4
|
export type WatchTarget = {
|
|
5
5
|
commandId: string;
|
package/dist/lib/dev-watch.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join, sep } from 'node:path';
|
|
3
|
-
export const RESOURCE_TYPES = ['snippets', 'api', 'plugins'];
|
|
3
|
+
export const RESOURCE_TYPES = ['snippets', 'pages', 'api', 'hooks', 'plugins'];
|
|
4
4
|
// `only` narrows the default set, `skip` then removes from whatever that set is, same
|
|
5
5
|
// precedence a combined `--only=a,b --skip=b` reads as: watch a, b, then drop b.
|
|
6
6
|
export function resolveResourceTypes(only, skip = []) {
|
|
@@ -15,6 +15,8 @@ export function buildWatchTargets(types, localConfig, cwd) {
|
|
|
15
15
|
const rootDir = join(cwd, localConfig.rootDir ?? '.');
|
|
16
16
|
const candidates = {
|
|
17
17
|
api: { commandId: 'api:push', path: join(rootDir, localConfig.apiDir ?? 'api'), type: 'api' },
|
|
18
|
+
hooks: { commandId: 'hook:push', path: join(rootDir, localConfig.hooksDir ?? 'hooks'), type: 'hooks' },
|
|
19
|
+
pages: { commandId: 'page:push', path: join(rootDir, localConfig.pageDir ?? 'pages'), type: 'pages' },
|
|
18
20
|
plugins: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugins' },
|
|
19
21
|
snippets: { commandId: 'snippet:push', path: join(rootDir, localConfig.snippetsDir ?? 'snippets'), type: 'snippets' },
|
|
20
22
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function stdoutToStderr<T>(enabled: boolean, work: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// `lps push`/`pull`/`promote --json` must put exactly one JSON document on stdout: that's what
|
|
2
|
+
// the MCP server (and any script) parses. The resource commands they delegate to aren't in JSON
|
|
3
|
+
// mode themselves, so their progress lines and Listr renderers would land on stdout ahead of it.
|
|
4
|
+
// While `enabled`, their stdout goes to stderr instead: still visible to a human, out of the
|
|
5
|
+
// JSON. Patching the stream's write is the one place every writer goes through (oclif's
|
|
6
|
+
// ux.stdout, Listr's renderers, a stray console.log).
|
|
7
|
+
export async function stdoutToStderr(enabled, work) {
|
|
8
|
+
if (!enabled)
|
|
9
|
+
return work();
|
|
10
|
+
const original = process.stdout.write;
|
|
11
|
+
process.stdout.write = process.stderr.write.bind(process.stderr);
|
|
12
|
+
try {
|
|
13
|
+
return await work();
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
process.stdout.write = original;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -70,7 +70,7 @@ async function loadSnippet(path, file, onSkip) {
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
// Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
|
|
73
|
-
// (publishes to the Loopress api as a shared source)
|
|
73
|
+
// (publishes to the Loopress api as a shared source): both read the same local `snippets/`
|
|
74
74
|
// directory the same way. `onSkip` is optional so callers that don't care about per-file
|
|
75
75
|
// warnings (e.g. `snippet publish`) can ignore them; skipped files are simply left out either way.
|
|
76
76
|
export async function loadSnippets(path, onSkip) {
|
|
@@ -341,9 +341,10 @@ export const API_FILES_RESOURCE = {
|
|
|
341
341
|
emptyListMessage: 'No API route files found',
|
|
342
342
|
endpoint: 'loopress/v1/api-files',
|
|
343
343
|
// Mirrors ApiFilesController::isValidFilename(): kebab-case segments or a bracketed dynamic
|
|
344
|
-
// segment like [order_id], joined by '/'.
|
|
345
|
-
|
|
346
|
-
|
|
344
|
+
// segment like [order_id], joined by '/', and never a root "index" (api/index.php is
|
|
345
|
+
// reserved; a nested orders/index.php is the /orders route).
|
|
346
|
+
filenamePattern: /^(?!index$)(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/,
|
|
347
|
+
invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens, or a bracketed dynamic segment like "[order_id]" (e.g. "invoice-pdf/[order_id].php"), and a root "index.php" is reserved: name the file, or nest it (orders/index.php is the /orders route)',
|
|
347
348
|
label: 'API routes',
|
|
348
349
|
listDescription: 'List custom API route files from WordPress',
|
|
349
350
|
noun: 'route file',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdir } from 'node:fs/promises';
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
3
|
export async function readdirTolerant(dir, options) {
|
|
4
4
|
try {
|
|
@@ -16,10 +16,20 @@ export async function readdirTolerant(dir, options) {
|
|
|
16
16
|
// never the OS separator: callers match these identities against server-sent filenames
|
|
17
17
|
// (which are always '/'-joined) or embed them in an API payload, so a Windows run must
|
|
18
18
|
// produce the exact same strings as a POSIX one. ENOENT on `dir` itself yields no files,
|
|
19
|
-
// same tolerance as readdirTolerant.
|
|
19
|
+
// same tolerance as readdirTolerant. A symlink to a file counts as that file (a project can
|
|
20
|
+
// link a route in from a shared checkout); a broken one, or one to a directory, is skipped.
|
|
20
21
|
export async function walkFiles(dir) {
|
|
21
22
|
const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
const files = [];
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
const path = join(entry.parentPath, entry.name);
|
|
26
|
+
if (entry.isFile() || (entry.isSymbolicLink() && (await isFile(path))))
|
|
27
|
+
files.push(relative(dir, path).split(sep).join('/'));
|
|
28
|
+
}
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
async function isFile(path) {
|
|
32
|
+
return stat(path)
|
|
33
|
+
.then((s) => s.isFile())
|
|
34
|
+
.catch(() => false);
|
|
25
35
|
}
|
|
@@ -4,6 +4,7 @@ import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js
|
|
|
4
4
|
import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
|
|
5
5
|
import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
|
|
6
6
|
import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
|
|
7
|
+
import { formatPageProblems, PAGES_ENDPOINT, readLocalPages } from '../utils/page-format.js';
|
|
7
8
|
import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
|
|
8
9
|
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
|
|
9
10
|
import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
|
|
@@ -194,6 +195,28 @@ const hookProvider = phpFilesProvider({
|
|
|
194
195
|
resource: 'hook',
|
|
195
196
|
title: 'Hooks',
|
|
196
197
|
});
|
|
198
|
+
// ---- Pages ------------------------------------------------------------------------------
|
|
199
|
+
// Keyed by slug (the page's identity on both sides). `link` is server-derived and never
|
|
200
|
+
// compared. A local problem (a `.php` in pages/, a bad header) fails the whole resource rather
|
|
201
|
+
// than hiding that page, the same stop-everything stance as `page push`.
|
|
202
|
+
function canonicalPage(page) {
|
|
203
|
+
return { html: page.html, status: page.status, title: page.title };
|
|
204
|
+
}
|
|
205
|
+
const pageProvider = {
|
|
206
|
+
dirKind: 'page',
|
|
207
|
+
async local(dir) {
|
|
208
|
+
const { pages, problems } = await readLocalPages(dir);
|
|
209
|
+
if (problems.length > 0)
|
|
210
|
+
throw new Error(formatPageProblems(problems));
|
|
211
|
+
return new Map(pages.map((page) => [page.slug, canonicalPage(page)]));
|
|
212
|
+
},
|
|
213
|
+
async remote(wp) {
|
|
214
|
+
const pages = await wp.get(PAGES_ENDPOINT);
|
|
215
|
+
return new Map(pages.map((page) => [page.slug, canonicalPage(page)]));
|
|
216
|
+
},
|
|
217
|
+
resource: 'page',
|
|
218
|
+
title: 'Pages',
|
|
219
|
+
};
|
|
197
220
|
// ---- SEO --------------------------------------------------------------------------------
|
|
198
221
|
function canonicalRedirect(redirect) {
|
|
199
222
|
return omit(redirect, REDIRECT_VOLATILE_KEYS);
|
|
@@ -403,6 +426,7 @@ export const RESOURCE_STATE_PROVIDERS = [
|
|
|
403
426
|
acfProvider,
|
|
404
427
|
apiProvider,
|
|
405
428
|
hookProvider,
|
|
429
|
+
pageProvider,
|
|
406
430
|
seoProvider,
|
|
407
431
|
menuProvider,
|
|
408
432
|
optionsProvider,
|
|
@@ -49,6 +49,7 @@ export async function writeSnapshot(options) {
|
|
|
49
49
|
const { afterState, beforeState, environment, resource, retain = SNAPSHOT_RETENTION, rootDir } = options;
|
|
50
50
|
const dir = snapshotsDir(rootDir, resource, environment);
|
|
51
51
|
await mkdir(dir, { recursive: true });
|
|
52
|
+
await ignoreCacheDirInGit(rootDir);
|
|
52
53
|
const snapshot = {
|
|
53
54
|
afterState: stateToRecord(afterState),
|
|
54
55
|
beforeState: stateToRecord(beforeState),
|
|
@@ -72,6 +73,19 @@ export async function writeSnapshot(options) {
|
|
|
72
73
|
await pruneSnapshots(rootDir, resource, environment, retain);
|
|
73
74
|
return String(id);
|
|
74
75
|
}
|
|
76
|
+
// `.loopress/` lives inside the user's project and holds the environment's full remote state
|
|
77
|
+
// (snippet code, form definitions, option values): a `git add .` must never pick it up. The
|
|
78
|
+
// directory ignores itself, the way `.pytest_cache` does, instead of relying on every project
|
|
79
|
+
// to add it to its own .gitignore. `wx` so a hand-edited file is never overwritten.
|
|
80
|
+
async function ignoreCacheDirInGit(rootDir) {
|
|
81
|
+
try {
|
|
82
|
+
await writeFile(join(rootDir, '.loopress', '.gitignore'), '# Written by the Loopress CLI: local rollback snapshots, never commit them.\n*\n', { flag: 'wx' });
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (!isEexist(error))
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
75
89
|
// Newest first. A missing snapshots directory (never pushed, or pruned to nothing) reads as
|
|
76
90
|
// "no snapshots", not an error, the same ENOENT tolerance every resource-state provider gives
|
|
77
91
|
// a never-pulled local directory.
|