@loopress/cli 0.26.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 +100 -94
- package/dist/commands/init.js +5 -1
- package/dist/commands/page/push.js +2 -2
- 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 +6 -2
- 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 +2 -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/snapshot-store.js +14 -0
- package/dist/lib/validate-local.js +28 -7
- package/dist/lib/wp-client.js +31 -2
- package/dist/utils/loopress-config.d.ts +1 -0
- package/dist/utils/loopress-config.js +14 -0
- package/dist/utils/page-format.d.ts +4 -0
- package/dist/utils/page-format.js +7 -0
- package/oclif.manifest.json +19 -5
- package/package.json +5 -3
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 = [
|
|
@@ -68,6 +68,7 @@ export default class Init extends Command {
|
|
|
68
68
|
config[feature.key] = feature.dir;
|
|
69
69
|
}
|
|
70
70
|
await writeLocalConfig(config);
|
|
71
|
+
const isGitattributesUpdated = await ensureLfGitattributes();
|
|
71
72
|
let isProviderAdded = false;
|
|
72
73
|
if (providerChoice !== '__none__') {
|
|
73
74
|
try {
|
|
@@ -89,6 +90,9 @@ export default class Init extends Command {
|
|
|
89
90
|
if (isProviderAdded) {
|
|
90
91
|
this.log(` Plugin: ${providerChoice}`);
|
|
91
92
|
}
|
|
93
|
+
if (isGitattributesUpdated) {
|
|
94
|
+
this.log('✓ .gitattributes: LF line endings, so files match across macOS, Linux and Windows');
|
|
95
|
+
}
|
|
92
96
|
this.log('\n→ Next: run `lps snippet pull` to fetch what is already on the site, or `lps doctor` to verify the connection.');
|
|
93
97
|
}
|
|
94
98
|
// When nothing is configured yet, the useful path is configuring a project right here, not
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { PushCommand } from '../../lib/push-command.js';
|
|
3
|
-
import { formatPageProblems, PAGES_ENDPOINT, readLocalPages } from '../../utils/page-format.js';
|
|
3
|
+
import { formatPageProblems, frontPageNote, PAGES_ENDPOINT, readLocalPages } from '../../utils/page-format.js';
|
|
4
4
|
import { pluralize } from '../../utils/pluralize.js';
|
|
5
5
|
import { resolveResourceDir } from '../../utils/resource-dirs.js';
|
|
6
6
|
export default class Push extends PushCommand {
|
|
@@ -51,7 +51,7 @@ export default class Push extends PushCommand {
|
|
|
51
51
|
try {
|
|
52
52
|
const result = await this.wp.put(PAGES_ENDPOINT, page);
|
|
53
53
|
if (task)
|
|
54
|
-
task.output = `Pushed: ${page.slug} (${result.status}) ${result.link}`;
|
|
54
|
+
task.output = `Pushed: ${page.slug} (${result.status}) ${result.link}${frontPageNote(result)}`;
|
|
55
55
|
}
|
|
56
56
|
catch (error) {
|
|
57
57
|
this.reportTaskFailure(`Failed to push ${page.slug}: ${error.message}`, error, task);
|
|
@@ -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.
|
|
@@ -32,7 +33,7 @@ export default class Push extends LoopressCommand {
|
|
|
32
33
|
for (const target of PUSH_TARGETS) {
|
|
33
34
|
this.log(`\n→ Pushing ${target.label}...`);
|
|
34
35
|
try {
|
|
35
|
-
await this.config.runCommand(target.commandId, argv);
|
|
36
|
+
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
|
|
36
37
|
this.log(`✓ ${target.label} pushed`);
|
|
37
38
|
results.push({ label: target.label, status: 'pushed' });
|
|
38
39
|
}
|
|
@@ -44,7 +45,10 @@ export default class Push extends LoopressCommand {
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
if (this.failedCount > 0) {
|
|
47
|
-
|
|
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('; ')}`);
|
|
48
52
|
}
|
|
49
53
|
this.log('\nAll resources pushed.');
|
|
50
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' | 'pages' | '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', 'pages', '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,7 @@ 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' },
|
|
18
19
|
pages: { commandId: 'page:push', path: join(rootDir, localConfig.pageDir ?? 'pages'), type: 'pages' },
|
|
19
20
|
plugins: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugins' },
|
|
20
21
|
snippets: { commandId: 'snippet:push', path: join(rootDir, localConfig.snippetsDir ?? 'snippets'), type: 'snippets' },
|
|
@@ -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
|
}
|
|
@@ -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.
|
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { configManager } from '../config/project-config.manager.js';
|
|
4
|
+
import { parseLocalOption } from '../utils/option-format.js';
|
|
4
5
|
import { readLocalPages } from '../utils/page-format.js';
|
|
5
6
|
import { parseType } from '../utils/snippet-format.js';
|
|
6
7
|
import { loadSnippets } from './load-snippets.js';
|
|
7
8
|
import { readdirTolerant } from './readdir-tolerant.js';
|
|
8
9
|
// Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
|
|
9
|
-
// and api routes (`.php`, not JSON) are checked separately.
|
|
10
|
-
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir', 'menuDir'];
|
|
10
|
+
// and api routes (`.php`, not JSON) are checked separately, options too (a stricter shape).
|
|
11
|
+
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir', 'menuDir', 'themeStylesDir'];
|
|
11
12
|
const DEFAULT_DIR = {
|
|
12
13
|
acfDir: 'acf',
|
|
13
14
|
apiDir: 'api',
|
|
14
15
|
formDir: 'forms',
|
|
15
16
|
hooksDir: 'hooks',
|
|
16
17
|
menuDir: 'menus',
|
|
18
|
+
optionsDir: 'options',
|
|
17
19
|
pageDir: 'pages',
|
|
18
20
|
seoDir: 'seo',
|
|
19
21
|
snippetsDir: 'snippets',
|
|
22
|
+
themeStylesDir: 'theme',
|
|
20
23
|
};
|
|
21
24
|
// Inspects the local tracked files under `cwd` without contacting WordPress: JSON files parse
|
|
22
25
|
// and have the right shape, snippet sidecars use a known type, `loopress.json` points at a
|
|
@@ -37,6 +40,9 @@ export async function validateLocal(cwd) {
|
|
|
37
40
|
for (const key of JSON_RESOURCE_DIRS) {
|
|
38
41
|
checked += await checkJsonDir(resolve(key), problems);
|
|
39
42
|
}
|
|
43
|
+
// Same reader `option push` uses: a file missing "name", "autoload" or "value" is skipped
|
|
44
|
+
// there with a warning, so it's a problem here.
|
|
45
|
+
checked += await checkJsonDir(resolve('optionsDir'), problems, parseLocalOption);
|
|
40
46
|
checked += await checkSnippets(resolve('snippetsDir'), problems);
|
|
41
47
|
checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty');
|
|
42
48
|
checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty');
|
|
@@ -91,15 +97,23 @@ async function checkComposerJson(cwd, problems) {
|
|
|
91
97
|
}
|
|
92
98
|
return 1;
|
|
93
99
|
}
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
// Recursive: ACF keeps one subdirectory per object type (acf/field-groups/*.json) and SEO its
|
|
101
|
+
// post meta and redirects (seo/post-meta/<type>/*.json, seo/redirects/*.json); a top-level-only
|
|
102
|
+
// scan silently never checked any of those. `shape` runs on files that are JSON objects and
|
|
103
|
+
// throws to report a resource-specific problem.
|
|
104
|
+
async function checkJsonDir(dir, problems, shape) {
|
|
105
|
+
const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
|
|
96
106
|
let checked = 0;
|
|
97
|
-
for (const
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (!entry.isFile() || !entry.name.endsWith('.json'))
|
|
109
|
+
continue;
|
|
98
110
|
checked += 1;
|
|
99
|
-
|
|
111
|
+
// parentPath, not dir: see checkPhpDir.
|
|
112
|
+
const filePath = join(entry.parentPath, entry.name);
|
|
113
|
+
const raw = await readFile(filePath, 'utf8');
|
|
100
114
|
let parsed;
|
|
101
115
|
try {
|
|
102
|
-
parsed = JSON.parse(
|
|
116
|
+
parsed = JSON.parse(raw);
|
|
103
117
|
}
|
|
104
118
|
catch (error) {
|
|
105
119
|
problems.push({ file: filePath, message: `not valid JSON: ${error.message}` });
|
|
@@ -107,6 +121,13 @@ async function checkJsonDir(dir, problems) {
|
|
|
107
121
|
}
|
|
108
122
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
109
123
|
problems.push({ file: filePath, message: 'expected a JSON object' });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
shape?.(raw);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
problems.push({ file: filePath, message: error.message });
|
|
110
131
|
}
|
|
111
132
|
}
|
|
112
133
|
return checked;
|
package/dist/lib/wp-client.js
CHANGED
|
@@ -107,6 +107,13 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
107
107
|
const err = error;
|
|
108
108
|
const status = err.response?.statusCode;
|
|
109
109
|
if (status === 401 || status === 403) {
|
|
110
|
+
// A Loopress controller's own `{"error": ...}` on a 403 is a deliberate refusal of this one
|
|
111
|
+
// request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
|
|
112
|
+
// pointing at `lps project config` there sends the user the wrong way. WordPress core's own
|
|
113
|
+
// auth failures use `{code, message}` instead and keep the credentials hint.
|
|
114
|
+
const refusal = extractLoopressError(err.response?.body);
|
|
115
|
+
if (refusal)
|
|
116
|
+
return `Request refused (${status}) on ${url}: ${refusal}`;
|
|
110
117
|
return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
|
|
111
118
|
}
|
|
112
119
|
if (status === 404) {
|
|
@@ -124,6 +131,28 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
124
131
|
}
|
|
125
132
|
return `Request to ${url} failed: ${err.message ?? String(error)}`;
|
|
126
133
|
}
|
|
134
|
+
function extractLoopressError(body) {
|
|
135
|
+
if (!body)
|
|
136
|
+
return undefined;
|
|
137
|
+
try {
|
|
138
|
+
const { error } = JSON.parse(body);
|
|
139
|
+
return typeof error === 'string' && error.trim() ? decodeEntities(error) : undefined;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Plugin exception messages go through esc_html() (WordPress coding standards require it for
|
|
146
|
+
// anything thrown), so quotes and ampersands reach the JSON body as entities. They're meant for
|
|
147
|
+
// a terminal here, not an HTML page. `&` last, so "&quot;" stays a literal """.
|
|
148
|
+
function decodeEntities(text) {
|
|
149
|
+
return text
|
|
150
|
+
.replaceAll('"', '"')
|
|
151
|
+
.replaceAll(''', "'")
|
|
152
|
+
.replaceAll('<', '<')
|
|
153
|
+
.replaceAll('>', '>')
|
|
154
|
+
.replaceAll('&', '&');
|
|
155
|
+
}
|
|
127
156
|
// The Loopress plugin's own controllers reply with `{"error": "..."}`; a WP_Error-based
|
|
128
157
|
// core response (e.g. an uncaught fatal formatted by WordPress itself) uses `{"message": "..."}`.
|
|
129
158
|
// Surfacing this is what makes a deliberately clear server-side error (e.g. "Multiple snippet
|
|
@@ -131,7 +160,7 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
131
160
|
//
|
|
132
161
|
// Some controllers (ComposerController::sync(), notably) pair a short, generic `error` (e.g.
|
|
133
162
|
// "Sync failed.") with the real detail in a separate `output` field (the raw Composer trace),
|
|
134
|
-
// specifically so a caller that only reads `error` doesn't see it
|
|
163
|
+
// specifically so a caller that only reads `error` doesn't see it, which is exactly what this
|
|
135
164
|
// function used to do, hiding the one piece of text that actually explains the failure.
|
|
136
165
|
function extractServerErrorMessage(body) {
|
|
137
166
|
if (!body)
|
|
@@ -145,7 +174,7 @@ function extractServerErrorMessage(body) {
|
|
|
145
174
|
if (parsed.code === 'rest_no_route')
|
|
146
175
|
return undefined;
|
|
147
176
|
const reason = parsed.error ?? parsed.message;
|
|
148
|
-
const summary = typeof reason === 'string' && reason.trim() ? reason : undefined;
|
|
177
|
+
const summary = typeof reason === 'string' && reason.trim() ? decodeEntities(reason) : undefined;
|
|
149
178
|
const detail = typeof parsed.output === 'string' && parsed.output.trim() ? parsed.output.trim() : undefined;
|
|
150
179
|
if (summary && detail)
|
|
151
180
|
return `${summary}\n${detail}`;
|
|
@@ -2,3 +2,4 @@ import { type LoopressProjectConfiguration } from '../types/project-config.gener
|
|
|
2
2
|
export type LoopressLocalConfig = LoopressProjectConfiguration;
|
|
3
3
|
export declare function readLocalConfig(): Promise<LoopressLocalConfig>;
|
|
4
4
|
export declare function writeLocalConfig(config: LoopressLocalConfig): Promise<void>;
|
|
5
|
+
export declare function ensureLfGitattributes(): Promise<boolean>;
|
|
@@ -20,3 +20,17 @@ export async function writeLocalConfig(config) {
|
|
|
20
20
|
const configPath = join(process.cwd(), 'loopress.json');
|
|
21
21
|
await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
22
22
|
}
|
|
23
|
+
// Git for Windows checks text files out as CRLF by default (core.autocrlf=true), so the same
|
|
24
|
+
// snippet would differ byte for byte between a Windows checkout and what `pull` writes (LF).
|
|
25
|
+
// Pinning LF in the repo keeps synced files identical on every OS. Returns false when the rule
|
|
26
|
+
// is already there, so re-running `lps init` never duplicates it. The rule goes first: in
|
|
27
|
+
// .gitattributes a later line wins, so existing exceptions (`*.bat text eol=crlf`) keep theirs.
|
|
28
|
+
const LF_RULE = '* text=auto eol=lf';
|
|
29
|
+
export async function ensureLfGitattributes() {
|
|
30
|
+
const path = join(process.cwd(), '.gitattributes');
|
|
31
|
+
const current = existsSync(path) ? await readFile(path, 'utf8') : '';
|
|
32
|
+
if (current.split(/\r?\n/).some((line) => line.trim() === LF_RULE))
|
|
33
|
+
return false;
|
|
34
|
+
await writeFile(path, `${LF_RULE}\n${current}`, 'utf8');
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
@@ -14,6 +14,9 @@ export type Page = {
|
|
|
14
14
|
export type RemotePage = Page & {
|
|
15
15
|
link: string;
|
|
16
16
|
};
|
|
17
|
+
export type PushedPage = RemotePage & {
|
|
18
|
+
frontPage?: 'set' | 'unset';
|
|
19
|
+
};
|
|
17
20
|
export type PageProblem = {
|
|
18
21
|
file: string;
|
|
19
22
|
message: string;
|
|
@@ -25,3 +28,4 @@ export declare function readLocalPages(dir: string): Promise<{
|
|
|
25
28
|
pages: Page[];
|
|
26
29
|
problems: PageProblem[];
|
|
27
30
|
}>;
|
|
31
|
+
export declare function frontPageNote(page: PushedPage): string;
|
|
@@ -13,6 +13,10 @@ function parseBoolean(key, value) {
|
|
|
13
13
|
throw new Error(`"${key}" must be "true" or "false", got "${value}"`);
|
|
14
14
|
return value === 'true';
|
|
15
15
|
}
|
|
16
|
+
const FRONT_PAGE_NOTES = {
|
|
17
|
+
set: 'now the site front page',
|
|
18
|
+
unset: 'no longer the front page, the site shows its latest posts',
|
|
19
|
+
};
|
|
16
20
|
export function formatPageProblems(problems) {
|
|
17
21
|
return problems.map((problem) => `${problem.file}: ${problem.message}`).join('\n ');
|
|
18
22
|
}
|
|
@@ -109,3 +113,6 @@ export async function readLocalPages(dir) {
|
|
|
109
113
|
}
|
|
110
114
|
return { pages, problems };
|
|
111
115
|
}
|
|
116
|
+
export function frontPageNote(page) {
|
|
117
|
+
return page.frontPage === undefined ? '' : ` (${FRONT_PAGE_NOTES[page.frontPage]})`;
|
|
118
|
+
}
|