@loopress/cli 0.17.0 → 0.19.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 +266 -59
- package/dist/commands/acf/pull.d.ts +1 -1
- package/dist/commands/acf/pull.js +12 -25
- package/dist/commands/acf/push.d.ts +1 -1
- package/dist/commands/acf/push.js +18 -47
- package/dist/commands/api/list.d.ts +9 -0
- package/dist/commands/api/list.js +26 -0
- package/dist/commands/api/pull.d.ts +13 -0
- package/dist/commands/api/pull.js +49 -0
- package/dist/commands/api/push.d.ts +15 -0
- package/dist/commands/api/push.js +70 -0
- package/dist/commands/composer/init.js +11 -1
- package/dist/commands/composer/push.d.ts +1 -0
- package/dist/commands/composer/push.js +17 -4
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.js +79 -0
- package/dist/commands/form/list.d.ts +9 -0
- package/dist/commands/form/list.js +26 -0
- package/dist/commands/form/pull.d.ts +13 -0
- package/dist/commands/form/pull.js +60 -0
- package/dist/commands/form/push.d.ts +16 -0
- package/dist/commands/form/push.js +122 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +64 -33
- package/dist/commands/plugin/push.d.ts +1 -1
- package/dist/commands/plugin/push.js +2 -14
- package/dist/commands/project/config.js +4 -0
- package/dist/commands/project/push.d.ts +5 -0
- package/dist/commands/project/push.js +21 -11
- package/dist/commands/seo/pull.d.ts +1 -3
- package/dist/commands/seo/pull.js +15 -43
- package/dist/commands/seo/push.d.ts +1 -1
- package/dist/commands/seo/push.js +4 -17
- package/dist/commands/snippet/publish.js +2 -2
- package/dist/commands/snippet/pull.d.ts +1 -4
- package/dist/commands/snippet/pull.js +12 -60
- package/dist/commands/snippet/push.d.ts +1 -1
- package/dist/commands/snippet/push.js +4 -13
- package/dist/commands/status.d.ts +4 -0
- package/dist/commands/status.js +25 -4
- package/dist/config/project-config.manager.js +2 -2
- package/dist/lib/api-client.d.ts +2 -1
- package/dist/lib/api-client.js +10 -3
- package/dist/lib/base.d.ts +11 -0
- package/dist/lib/base.js +71 -2
- package/dist/lib/find-orphaned-files.d.ts +7 -0
- package/dist/lib/find-orphaned-files.js +29 -0
- package/dist/lib/interactive.d.ts +1 -0
- package/dist/lib/interactive.js +7 -0
- package/dist/lib/load-files.d.ts +5 -0
- package/dist/lib/load-files.js +31 -0
- package/dist/lib/push-command.d.ts +7 -0
- package/dist/lib/push-command.js +40 -2
- package/dist/lib/wp-client.d.ts +9 -4
- package/dist/lib/wp-client.js +26 -12
- package/dist/types/project-config.generated.d.ts +8 -0
- package/dist/utils/composer.d.ts +3 -0
- package/dist/utils/form-format.d.ts +3 -0
- package/dist/utils/form-format.js +17 -0
- package/dist/utils/seo-format.d.ts +1 -0
- package/dist/utils/seo-format.js +5 -0
- package/dist/utils/snippet-format.d.ts +2 -0
- package/dist/utils/snippet-format.js +27 -1
- package/dist/utils/to-slug.d.ts +1 -0
- package/dist/utils/to-slug.js +7 -0
- package/oclif.manifest.json +748 -96
- package/package.json +26 -17
- package/schemas/project-config.schema.json +10 -0
|
@@ -7,9 +7,9 @@ export default class Pull extends LoopressCommand {
|
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
9
|
type: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
11
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
12
|
};
|
|
12
13
|
run(): Promise<void>;
|
|
13
|
-
private findOrphanedFiles;
|
|
14
14
|
private pullType;
|
|
15
15
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
|
-
import { mkdir,
|
|
4
|
-
import {
|
|
3
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
5
|
import { LoopressCommand } from '../../lib/base.js';
|
|
6
|
+
import { basenameKey, findOrphanedFiles } from '../../lib/find-orphaned-files.js';
|
|
6
7
|
import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../../utils/acf-format.js';
|
|
7
8
|
export default class Pull extends LoopressCommand {
|
|
8
9
|
static args = {
|
|
@@ -12,6 +13,7 @@ export default class Pull extends LoopressCommand {
|
|
|
12
13
|
static examples = ['$ lps acf pull', '$ lps acf pull --type field-groups'];
|
|
13
14
|
static flags = {
|
|
14
15
|
...LoopressCommand.dryRunFlag,
|
|
16
|
+
...LoopressCommand.yesFlag,
|
|
15
17
|
type: Flags.string({ description: 'Limit to specific ACF object types', multiple: true, options: ACF_OBJECT_TYPES }),
|
|
16
18
|
};
|
|
17
19
|
async run() {
|
|
@@ -25,28 +27,18 @@ export default class Pull extends LoopressCommand {
|
|
|
25
27
|
await this.pullType(type, path);
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
|
-
// Every file in a type's subdirectory is unambiguously `<key>.json` — unlike snippets, ACF
|
|
29
|
-
// objects have no numeric-id/slug filename convention. `key` is the stable identity ACF
|
|
30
|
-
// itself already uses for this (see its own Local JSON mechanism), so orphan detection is a
|
|
31
|
-
// plain set difference, no regex/extension juggling needed.
|
|
32
|
-
async findOrphanedFiles(dir, keepKeys) {
|
|
33
|
-
let files;
|
|
34
|
-
try {
|
|
35
|
-
files = await readdir(dir);
|
|
36
|
-
}
|
|
37
|
-
catch (error) {
|
|
38
|
-
if (error.code === 'ENOENT')
|
|
39
|
-
return [];
|
|
40
|
-
throw error;
|
|
41
|
-
}
|
|
42
|
-
return files.filter((file) => extname(file) === '.json' && !keepKeys.has(file.slice(0, -'.json'.length)));
|
|
43
|
-
}
|
|
44
30
|
async pullType(type, basePath) {
|
|
45
31
|
const dir = join(basePath, type);
|
|
46
32
|
const remoteList = await this.wp.get(acfEndpoint(type));
|
|
47
33
|
const withKey = remoteList.filter((object) => getAcfKey(object) !== null);
|
|
48
34
|
const skipped = remoteList.length - withKey.length;
|
|
49
|
-
|
|
35
|
+
// Every file in a type's subdirectory is unambiguously `<key>.json`: `key` is the stable
|
|
36
|
+
// identity ACF itself already uses (see its own Local JSON mechanism), no numeric-id/slug
|
|
37
|
+
// filename convention like snippets.
|
|
38
|
+
const orphans = await findOrphanedFiles(dir, new Set(withKey.map((object) => getAcfKey(object))), {
|
|
39
|
+
extensions: ['.json'],
|
|
40
|
+
key: basenameKey,
|
|
41
|
+
});
|
|
50
42
|
if (this.dryRun) {
|
|
51
43
|
this.log(`[dry-run] Would pull ${withKey.length} ${type} to ${dir}`);
|
|
52
44
|
if (orphans.length > 0) {
|
|
@@ -66,12 +58,7 @@ export default class Pull extends LoopressCommand {
|
|
|
66
58
|
title: `Pull ${key}`,
|
|
67
59
|
};
|
|
68
60
|
})).run();
|
|
69
|
-
|
|
70
|
-
await rm(join(dir, file), { force: true });
|
|
71
|
-
}
|
|
72
|
-
if (orphans.length > 0) {
|
|
73
|
-
this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${dir} no longer present on WordPress: ${orphans.join(', ')}`);
|
|
74
|
-
}
|
|
61
|
+
await this.removeOrphanedFiles(dir, orphans, `in ${dir} no longer present on WordPress`);
|
|
75
62
|
this.log(`Pulled ${withKey.length} ${type} to ${dir}`);
|
|
76
63
|
if (skipped > 0) {
|
|
77
64
|
this.warn(`${skipped} ${type} skipped because they have no key`);
|
|
@@ -7,9 +7,9 @@ export default class Push extends PushCommand {
|
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
9
|
type: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
11
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
12
|
};
|
|
12
|
-
private failedCount;
|
|
13
13
|
run(): Promise<void>;
|
|
14
14
|
private loadObjects;
|
|
15
15
|
private pushObject;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { loadFiles } from '../../lib/load-files.js';
|
|
5
5
|
import { PushCommand } from '../../lib/push-command.js';
|
|
6
6
|
import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../../utils/acf-format.js';
|
|
7
7
|
export default class Push extends PushCommand {
|
|
@@ -12,9 +12,9 @@ export default class Push extends PushCommand {
|
|
|
12
12
|
static examples = ['$ lps acf push', '$ lps acf push --type field-groups'];
|
|
13
13
|
static flags = {
|
|
14
14
|
...PushCommand.dryRunFlag,
|
|
15
|
+
...PushCommand.yesFlag,
|
|
15
16
|
type: Flags.string({ description: 'Limit to specific ACF object types', multiple: true, options: ACF_OBJECT_TYPES }),
|
|
16
17
|
};
|
|
17
|
-
failedCount = 0;
|
|
18
18
|
async run() {
|
|
19
19
|
const { args, flags } = await this.parse(Push);
|
|
20
20
|
const { url } = this.siteConfig;
|
|
@@ -33,45 +33,22 @@ export default class Push extends PushCommand {
|
|
|
33
33
|
await this.recordSuccess();
|
|
34
34
|
this.log('All ACF objects pushed.');
|
|
35
35
|
}
|
|
36
|
-
// One object's file is read in isolation: a corrupted or hand-broken JSON file (or one
|
|
37
|
-
// missing/emptying its "key") must only skip that object, not abort loading the rest of
|
|
38
|
-
// the type's directory — same principle as loadSnippets() for the snippet feature.
|
|
39
36
|
async loadObjects(dir) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (extname(file) !== '.json')
|
|
52
|
-
continue;
|
|
53
|
-
const filePath = join(dir, file);
|
|
54
|
-
let parsed;
|
|
55
|
-
try {
|
|
56
|
-
parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
|
57
|
-
}
|
|
58
|
-
catch (error) {
|
|
59
|
-
this.warn(`Skipping "${filePath}": ${error.message}`);
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
if (typeof parsed !== 'object' || parsed === null || getAcfKey(parsed) === null) {
|
|
63
|
-
this.warn(`Skipping "${filePath}": missing or invalid "key"`);
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
objects.push(parsed);
|
|
67
|
-
}
|
|
68
|
-
return objects;
|
|
37
|
+
return loadFiles(dir, {
|
|
38
|
+
extension: '.json',
|
|
39
|
+
onSkip: (message) => this.warn(message),
|
|
40
|
+
parse(raw) {
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
if (typeof parsed !== 'object' || parsed === null || getAcfKey(parsed) === null) {
|
|
43
|
+
throw new Error('missing or invalid "key"');
|
|
44
|
+
}
|
|
45
|
+
return parsed;
|
|
46
|
+
},
|
|
47
|
+
});
|
|
69
48
|
}
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// that server-side via the object's `key`), unlike snippet push there's no numeric-id PUT/404
|
|
74
|
-
// fallback dance, and no rename-on-push step since `key` is permanently stable.
|
|
49
|
+
// POST alone covers create-or-update (the controller resolves that server-side via the
|
|
50
|
+
// object's `key`), unlike snippet push there's no numeric-id PUT/404 fallback dance, and no
|
|
51
|
+
// rename-on-push step since `key` is permanently stable.
|
|
75
52
|
async pushObject(type, object, task) {
|
|
76
53
|
const key = getAcfKey(object) ?? '(unknown)';
|
|
77
54
|
if (this.dryRun) {
|
|
@@ -85,13 +62,7 @@ export default class Push extends PushCommand {
|
|
|
85
62
|
task.output = `Pushed: ${key}`;
|
|
86
63
|
}
|
|
87
64
|
catch (error) {
|
|
88
|
-
|
|
89
|
-
if (task)
|
|
90
|
-
task.output = message;
|
|
91
|
-
else
|
|
92
|
-
this.warn(` ${message}`);
|
|
93
|
-
this.failedCount++;
|
|
94
|
-
throw error;
|
|
65
|
+
this.reportTaskFailure(`Failed to push ${key}: ${error.message}`, error, task);
|
|
95
66
|
}
|
|
96
67
|
}
|
|
97
68
|
async pushType(type, basePath) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
export default class List extends LoopressCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
};
|
|
8
|
+
run(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
3
|
+
export default class List extends LoopressCommand {
|
|
4
|
+
static description = 'List custom API route files from WordPress';
|
|
5
|
+
static examples = ['$ lps api list'];
|
|
6
|
+
static flags = {
|
|
7
|
+
json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
|
|
8
|
+
};
|
|
9
|
+
async run() {
|
|
10
|
+
const { flags } = await this.parse(List);
|
|
11
|
+
const files = await this.wp.get('loopress/v1/api-files');
|
|
12
|
+
if (flags.json) {
|
|
13
|
+
this.log(JSON.stringify(files, null, 2));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (files.length === 0) {
|
|
17
|
+
this.log('No API route files found');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
this.log(`Found ${files.length} route file${files.length === 1 ? '' : 's'}:`);
|
|
21
|
+
this.log('');
|
|
22
|
+
for (const file of files) {
|
|
23
|
+
this.log(` ${file.filename}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
export default class Pull extends LoopressCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
};
|
|
12
|
+
run(): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { Listr } from 'listr2';
|
|
3
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
6
|
+
import { basenameKey, findOrphanedFiles } from '../../lib/find-orphaned-files.js';
|
|
7
|
+
export default class Pull extends LoopressCommand {
|
|
8
|
+
static args = {
|
|
9
|
+
path: Args.string({ description: 'Path to api directory (overrides project config)' }),
|
|
10
|
+
};
|
|
11
|
+
static description = 'Pull custom API route files from WordPress';
|
|
12
|
+
static examples = ['$ lps api pull', '$ lps api pull --path ./api'];
|
|
13
|
+
static flags = {
|
|
14
|
+
...LoopressCommand.dryRunFlag,
|
|
15
|
+
...LoopressCommand.yesFlag,
|
|
16
|
+
};
|
|
17
|
+
async run() {
|
|
18
|
+
const { args } = await this.parse(Pull);
|
|
19
|
+
const { url } = this.siteConfig;
|
|
20
|
+
const path = this.resolveApiPath(args.path);
|
|
21
|
+
this.log(`Pulling API routes from ${url}`);
|
|
22
|
+
this.log(`API path: ${path}`);
|
|
23
|
+
const files = await this.wp.get('loopress/v1/api-files');
|
|
24
|
+
// A `<filename>.php` no longer present remotely belongs to a route deleted on WordPress
|
|
25
|
+
// (push itself stays additive-only, but pull already cleans up locally, same as
|
|
26
|
+
// `snippet pull`).
|
|
27
|
+
const orphans = await findOrphanedFiles(path, new Set(files.map((file) => file.filename)), {
|
|
28
|
+
extensions: ['.php'],
|
|
29
|
+
key: basenameKey,
|
|
30
|
+
});
|
|
31
|
+
if (this.dryRun) {
|
|
32
|
+
this.log(`[dry-run] Would pull ${files.length} route file${files.length === 1 ? '' : 's'} to ${path}`);
|
|
33
|
+
if (orphans.length > 0) {
|
|
34
|
+
this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} whose route no longer exists on WordPress: ${orphans.join(', ')}`);
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await mkdir(path, { recursive: true });
|
|
39
|
+
await new Listr(files.map((file) => ({
|
|
40
|
+
async task(_ctx, task) {
|
|
41
|
+
await writeFile(join(path, `${file.filename}.php`), file.content);
|
|
42
|
+
task.output = `Pulled: ${file.filename}`;
|
|
43
|
+
},
|
|
44
|
+
title: `Pull ${file.filename}`,
|
|
45
|
+
}))).run();
|
|
46
|
+
await this.removeOrphanedFiles(path, orphans, 'whose route no longer exists on WordPress');
|
|
47
|
+
this.log(`Pulled ${files.length} route file${files.length === 1 ? '' : 's'} to ${path}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
export default class Push extends PushCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
};
|
|
12
|
+
run(): Promise<void>;
|
|
13
|
+
private loadFiles;
|
|
14
|
+
private pushFile;
|
|
15
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { Listr } from 'listr2';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { loadFiles as loadDirectoryFiles } from '../../lib/load-files.js';
|
|
5
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
6
|
+
// Mirrors the server's own allowlist (wordpress-plugin ApiFilesController::FILENAME_PATTERN):
|
|
7
|
+
// the filename becomes a URL path segment matched against this exact regex by the WP REST
|
|
8
|
+
// route itself. A filename that doesn't match never reaches the controller, WordPress's
|
|
9
|
+
// router returns a generic 404 before validate_callback runs, which the CLI's shared error
|
|
10
|
+
// formatter reports as "is the plugin installed?", a confusing message for what is actually
|
|
11
|
+
// an invalid filename. Checking client-side first turns that into an accurate error and
|
|
12
|
+
// skips a network round-trip that could only ever fail.
|
|
13
|
+
const FILENAME_PATTERN = /^[a-z0-9-]+$/;
|
|
14
|
+
export default class Push extends PushCommand {
|
|
15
|
+
static args = {
|
|
16
|
+
path: Args.string({ description: 'Path to api directory (overrides project config)' }),
|
|
17
|
+
};
|
|
18
|
+
static description = 'Push custom API route files to WordPress';
|
|
19
|
+
static examples = ['$ lps api push', '$ lps api push --path ./api'];
|
|
20
|
+
static flags = {
|
|
21
|
+
...PushCommand.dryRunFlag,
|
|
22
|
+
...PushCommand.yesFlag,
|
|
23
|
+
};
|
|
24
|
+
async run() {
|
|
25
|
+
const { args } = await this.parse(Push);
|
|
26
|
+
const { url } = this.siteConfig;
|
|
27
|
+
const path = this.resolveApiPath(args.path);
|
|
28
|
+
this.log(`Pushing API routes to ${url}`);
|
|
29
|
+
this.log(`API path: ${path}`);
|
|
30
|
+
const files = await this.loadFiles(path);
|
|
31
|
+
this.log(`Found ${files.length} route file${files.length === 1 ? '' : 's'} to push`);
|
|
32
|
+
await new Listr(files.map((file) => ({
|
|
33
|
+
task: async (_ctx, task) => this.pushFile(file, task),
|
|
34
|
+
title: `Push ${file.filename}`,
|
|
35
|
+
})), { concurrent: false, exitOnError: false }).run();
|
|
36
|
+
if (this.failedCount > 0) {
|
|
37
|
+
this.error(`${this.failedCount} route file${this.failedCount === 1 ? '' : 's'} failed to push.`);
|
|
38
|
+
}
|
|
39
|
+
if (this.dryRun)
|
|
40
|
+
return;
|
|
41
|
+
await this.recordSuccess();
|
|
42
|
+
this.log('All API routes pushed.');
|
|
43
|
+
}
|
|
44
|
+
async loadFiles(path) {
|
|
45
|
+
return loadDirectoryFiles(path, {
|
|
46
|
+
extension: '.php',
|
|
47
|
+
onSkip: (message) => this.warn(message),
|
|
48
|
+
parse: (raw, filePath) => ({ content: raw, filename: basename(filePath, '.php') }),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async pushFile(file, task) {
|
|
52
|
+
if (!FILENAME_PATTERN.test(file.filename)) {
|
|
53
|
+
const message = `Invalid filename "${file.filename}": only lowercase letters, digits, and hyphens are allowed (e.g. "hello-world.php")`;
|
|
54
|
+
this.reportTaskFailure(message, new Error(message), task);
|
|
55
|
+
}
|
|
56
|
+
if (this.dryRun) {
|
|
57
|
+
if (task)
|
|
58
|
+
task.output = `[dry-run] Would push: ${file.filename}`;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
await this.wp.put(`loopress/v1/api-files/${file.filename}`, { content: file.content });
|
|
63
|
+
if (task)
|
|
64
|
+
task.output = `Pushed: ${file.filename}`;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -3,11 +3,12 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { LoopressCommand } from '../../lib/base.js';
|
|
6
|
+
import { isInteractive } from '../../lib/interactive.js';
|
|
6
7
|
const WPACKAGIST_REPOSITORY = { type: 'composer', url: 'https://wpackagist.org' };
|
|
7
8
|
const INSTALLERS_PACKAGE = 'composer/installers';
|
|
8
9
|
const INSTALLERS_CONSTRAINT = '^2.0';
|
|
9
10
|
// The server runs Composer with `--working-dir` set to wp-content/loopress/ (see
|
|
10
|
-
// LoopressEnvironment::
|
|
11
|
+
// LoopressEnvironment::getLoopressDir), not the WordPress root, so installer-paths must climb out
|
|
11
12
|
// of that directory to land plugins/themes in their usual wp-content/ locations.
|
|
12
13
|
const INSTALLER_PATHS = {
|
|
13
14
|
'../plugins/{$name}/': ['type:wordpress-plugin'],
|
|
@@ -22,6 +23,10 @@ export default class ComposerInit extends LoopressCommand {
|
|
|
22
23
|
async run() {
|
|
23
24
|
const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
|
|
24
25
|
if (existsSync(composerJsonPath)) {
|
|
26
|
+
if (!isInteractive()) {
|
|
27
|
+
this.log('composer.json already exists, not overwriting (non-interactive terminal).');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
25
30
|
const overwrite = await confirm({ default: false, message: 'composer.json already exists. Overwrite?' });
|
|
26
31
|
if (!overwrite) {
|
|
27
32
|
this.log('Aborted.');
|
|
@@ -29,6 +34,11 @@ export default class ComposerInit extends LoopressCommand {
|
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
const composerJson = {
|
|
37
|
+
// composer/installers is itself a Composer plugin, and Composer 2.2+ refuses to run any
|
|
38
|
+
// plugin that isn't explicitly trusted when running non-interactively (which every
|
|
39
|
+
// `composer push` on the server does). Without this, the very first real push against
|
|
40
|
+
// this scaffold 500s on Composer's own plugin-trust gate before ever touching a package.
|
|
41
|
+
config: { 'allow-plugins': { [INSTALLERS_PACKAGE]: true } },
|
|
32
42
|
extra: { 'installer-paths': INSTALLER_PATHS },
|
|
33
43
|
name: 'loopress/site-dependencies',
|
|
34
44
|
repositories: [WPACKAGIST_REPOSITORY],
|
|
@@ -3,6 +3,7 @@ export default class ComposerPush extends PushCommand {
|
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
5
|
static flags: {
|
|
6
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
6
7
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
8
|
};
|
|
8
9
|
run(): Promise<void>;
|
|
@@ -2,11 +2,15 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { PushCommand } from '../../lib/push-command.js';
|
|
5
|
+
import { isTimeoutError } from '../../lib/wp-client.js';
|
|
6
|
+
// A cold composer install easily exceeds the global 30s timeout of WpClient.
|
|
7
|
+
const COMPOSER_SYNC_TIMEOUT_MS = 600_000;
|
|
5
8
|
export default class ComposerPush extends PushCommand {
|
|
6
9
|
static description = 'Push composer.json and composer.lock to WordPress and run composer install';
|
|
7
10
|
static examples = ['$ lps composer push', '$ lps composer push --dry-run'];
|
|
8
11
|
static flags = {
|
|
9
12
|
...PushCommand.dryRunFlag,
|
|
13
|
+
...PushCommand.yesFlag,
|
|
10
14
|
};
|
|
11
15
|
async run() {
|
|
12
16
|
const { url } = this.siteConfig;
|
|
@@ -29,10 +33,19 @@ export default class ComposerPush extends PushCommand {
|
|
|
29
33
|
}
|
|
30
34
|
if (this.dryRun)
|
|
31
35
|
return;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
this.log('Running composer install on the server, this can take a few minutes...');
|
|
37
|
+
try {
|
|
38
|
+
await this.wp.post('loopress/v1/composer/sync', {
|
|
39
|
+
composerJson: composerJsonRaw,
|
|
40
|
+
composerLock: composerLockRaw,
|
|
41
|
+
}, { timeoutMs: COMPOSER_SYNC_TIMEOUT_MS });
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (isTimeoutError(error)) {
|
|
45
|
+
this.error(`${error.message} The composer install may still be running on the server.`);
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
36
49
|
this.log('composer install completed on the server.');
|
|
37
50
|
await this.recordSuccess();
|
|
38
51
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { configManager } from '../config/project-config.manager.js';
|
|
2
|
+
import { LoopressCommand } from '../lib/base.js';
|
|
3
|
+
import { isNotFoundError } from '../lib/wp-client.js';
|
|
4
|
+
import { diagnoseWpSite } from '../lib/wp-site-diagnostic.js';
|
|
5
|
+
export default class Doctor extends LoopressCommand {
|
|
6
|
+
static description = 'Diagnose connectivity, plugin and credential problems for the targeted environment';
|
|
7
|
+
static examples = ['$ lps doctor', '$ lps doctor --env production'];
|
|
8
|
+
async run() {
|
|
9
|
+
const { name, token, url } = this.siteConfig;
|
|
10
|
+
const project = this.localConfig.projectId
|
|
11
|
+
? configManager.getProject(this.localConfig.projectId)
|
|
12
|
+
: configManager.getCurrentProject();
|
|
13
|
+
this.log(`Project: ${project?.name ?? '(unknown)'}`);
|
|
14
|
+
this.log(`Environment: ${name}`);
|
|
15
|
+
this.log(`URL: ${url}`);
|
|
16
|
+
this.log('');
|
|
17
|
+
let failed = 0;
|
|
18
|
+
const reachable = await this.check('WordPress REST API reachable', async () => {
|
|
19
|
+
const diagnostic = await diagnoseWpSite(url);
|
|
20
|
+
if (!diagnostic.ok)
|
|
21
|
+
throw new Error(diagnostic.reason);
|
|
22
|
+
});
|
|
23
|
+
if (!reachable) {
|
|
24
|
+
failed++;
|
|
25
|
+
this.log('- Remaining checks skipped while the site is unreachable.');
|
|
26
|
+
this.error('1 check failed.', { exit: 1 });
|
|
27
|
+
}
|
|
28
|
+
if (!token) {
|
|
29
|
+
this.log('✗ Credentials configured');
|
|
30
|
+
this.log(` No credentials stored for ${url}. Run \`lps project config\` to add them.`);
|
|
31
|
+
this.log('- Remaining checks skipped without credentials.');
|
|
32
|
+
this.error('1 check failed.', { exit: 1 });
|
|
33
|
+
}
|
|
34
|
+
// The namespace index answers regardless of which Loopress features are active, so a 404
|
|
35
|
+
// here can only mean the plugin itself is missing or outdated (WpClient's 404 message
|
|
36
|
+
// already says exactly that).
|
|
37
|
+
if (!(await this.check('Loopress plugin installed (loopress/v1 endpoints)', () => this.wp.get('loopress/v1')))) {
|
|
38
|
+
failed++;
|
|
39
|
+
}
|
|
40
|
+
// wp/v2/users/me is WordPress core and requires authentication, so it validates the
|
|
41
|
+
// application password without depending on any Loopress feature.
|
|
42
|
+
if (!(await this.check('Credentials accepted (authenticated request)', () => this.wp.get('wp/v2/users/me')))) {
|
|
43
|
+
failed++;
|
|
44
|
+
}
|
|
45
|
+
await this.reportPluginVersion();
|
|
46
|
+
this.log('');
|
|
47
|
+
if (failed > 0) {
|
|
48
|
+
this.error(`${failed} check${failed === 1 ? '' : 's'} failed.`, { exit: 1 });
|
|
49
|
+
}
|
|
50
|
+
this.log('All checks passed.');
|
|
51
|
+
}
|
|
52
|
+
async check(title, run) {
|
|
53
|
+
try {
|
|
54
|
+
await run();
|
|
55
|
+
this.log(`✓ ${title}`);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
this.log(`✗ ${title}`);
|
|
60
|
+
this.log(` ${error.message}`);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Informational only: Loopress Light does not expose loopress/v1/update, and an old plugin
|
|
65
|
+
// predates it, neither should flip the doctor to a failure when everything else works.
|
|
66
|
+
async reportPluginVersion() {
|
|
67
|
+
try {
|
|
68
|
+
const status = await this.wp.get('loopress/v1/update');
|
|
69
|
+
this.log(`✓ Plugin version: ${status.current_version ?? '(unknown)'}`);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (isNotFoundError(error)) {
|
|
73
|
+
this.log('- Plugin version: not exposed by this plugin edition.');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.log(`- Plugin version: could not be read. ${error.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
export default class List extends LoopressCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
};
|
|
8
|
+
run(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
3
|
+
import { FORM_ENDPOINT, getFormId, getFormTitle } from '../../utils/form-format.js';
|
|
4
|
+
export default class List extends LoopressCommand {
|
|
5
|
+
static description = 'List forms from WordPress';
|
|
6
|
+
static examples = ['$ lps form list'];
|
|
7
|
+
static flags = {
|
|
8
|
+
json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
|
|
9
|
+
};
|
|
10
|
+
async run() {
|
|
11
|
+
const { flags } = await this.parse(List);
|
|
12
|
+
const forms = await this.wp.get(FORM_ENDPOINT);
|
|
13
|
+
if (flags.json) {
|
|
14
|
+
this.log(JSON.stringify(forms, null, 2));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
this.log(`Forms (${forms.length}):`);
|
|
18
|
+
if (forms.length === 0) {
|
|
19
|
+
this.log(' (none)');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const form of forms) {
|
|
23
|
+
this.log(` ${getFormId(form) ?? '(no id)'}. ${getFormTitle(form)}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
export default class Pull extends LoopressCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
};
|
|
12
|
+
run(): Promise<void>;
|
|
13
|
+
}
|