@loopress/cli 0.20.0 → 0.21.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 +128 -62
- package/dist/commands/acf/list.js +2 -1
- package/dist/commands/acf/push.js +1 -1
- package/dist/commands/api/list.d.ts +7 -4
- package/dist/commands/api/list.js +3 -10
- package/dist/commands/api/publish.js +1 -1
- package/dist/commands/api/pull.d.ts +8 -1
- package/dist/commands/api/pull.js +13 -4
- package/dist/commands/api/push.d.ts +7 -1
- package/dist/commands/api/push.js +48 -18
- package/dist/commands/composer/init.js +6 -6
- package/dist/commands/composer/pull.d.ts +7 -1
- package/dist/commands/composer/pull.js +41 -4
- package/dist/commands/composer/push.d.ts +8 -1
- package/dist/commands/composer/push.js +3 -1
- package/dist/commands/dev.d.ts +13 -0
- package/dist/commands/dev.js +97 -0
- package/dist/commands/doctor.js +4 -4
- package/dist/commands/init.js +7 -7
- package/dist/commands/login.js +3 -3
- package/dist/commands/page/list.d.ts +2 -4
- package/dist/commands/page/list.js +3 -10
- package/dist/commands/page/pull.d.ts +13 -1
- package/dist/commands/page/pull.js +5 -2
- package/dist/commands/page/push.d.ts +11 -1
- package/dist/commands/page/push.js +25 -13
- package/dist/commands/plugin/add.js +3 -3
- package/dist/commands/plugin/pull.d.ts +7 -1
- package/dist/commands/plugin/pull.js +3 -1
- package/dist/commands/plugin/push.d.ts +9 -1
- package/dist/commands/plugin/push.js +13 -9
- package/dist/commands/project/config.js +8 -8
- package/dist/commands/project/list.js +2 -2
- package/dist/commands/project/push.js +5 -5
- package/dist/commands/project/switch.js +2 -2
- package/dist/commands/snippet/list.d.ts +3 -4
- package/dist/commands/snippet/list.js +3 -10
- package/dist/commands/snippet/pull.d.ts +13 -1
- package/dist/commands/snippet/pull.js +5 -2
- package/dist/commands/snippet/push.d.ts +11 -1
- package/dist/commands/snippet/push.js +15 -5
- package/dist/commands/status.d.ts +13 -1
- package/dist/commands/status.js +25 -18
- package/dist/config/auth.manager.d.ts +2 -2
- package/dist/config/project-config.manager.d.ts +1 -1
- package/dist/config/project-config.manager.js +5 -6
- package/dist/lib/base.d.ts +2 -2
- package/dist/lib/base.js +3 -2
- package/dist/lib/bootstrap-full-install.d.ts +1 -1
- package/dist/lib/dev-watch.d.ts +16 -0
- package/dist/lib/dev-watch.js +71 -0
- package/dist/lib/find-orphaned-files.d.ts +3 -2
- package/dist/lib/find-orphaned-files.js +40 -4
- package/dist/lib/github-release.js +1 -1
- package/dist/lib/interactive.js +1 -1
- package/dist/lib/load-files.d.ts +1 -0
- package/dist/lib/load-files.js +40 -11
- package/dist/lib/load-snippets.d.ts +1 -1
- package/dist/lib/load-snippets.js +11 -9
- package/dist/lib/local-callback-server.js +8 -6
- package/dist/lib/plugin-detection.d.ts +1 -1
- package/dist/lib/push-command.js +2 -2
- package/dist/lib/rotate-app-password.d.ts +1 -1
- package/dist/lib/rotate-app-password.js +1 -1
- package/dist/lib/temp-admin.d.ts +3 -3
- package/dist/lib/temp-admin.js +2 -2
- package/dist/lib/wp-authorize-flow.js +5 -1
- package/dist/lib/wp-client.d.ts +2 -2
- package/dist/lib/wp-client.js +11 -1
- package/dist/lib/wp-site-diagnostic.d.ts +9 -2
- package/dist/lib/wp-site-diagnostic.js +13 -23
- package/dist/types/plugin.d.ts +4 -4
- package/dist/types/snippet.d.ts +3 -3
- package/dist/utils/composer.d.ts +2 -2
- package/dist/utils/form-format.js +1 -1
- package/dist/utils/loopress-config.d.ts +1 -1
- package/dist/utils/page-format.js +2 -2
- package/dist/utils/plugins.d.ts +5 -5
- package/dist/utils/plugins.js +3 -3
- package/dist/utils/seo-format.d.ts +6 -6
- package/dist/utils/snippet-format.d.ts +2 -2
- package/dist/utils/snippet-format.js +10 -5
- package/oclif.manifest.json +153 -31
- package/package.json +9 -8
|
@@ -1,26 +1,19 @@
|
|
|
1
|
-
import { Flags } from '@oclif/core';
|
|
2
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
3
2
|
export default class List extends LoopressCommand {
|
|
4
3
|
static description = 'List custom API route files from WordPress';
|
|
4
|
+
static enableJsonFlag = true;
|
|
5
5
|
static examples = ['$ lps api list'];
|
|
6
|
-
static flags = {
|
|
7
|
-
json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
|
|
8
|
-
};
|
|
9
6
|
async run() {
|
|
10
|
-
const { flags } = await this.parse(List);
|
|
11
7
|
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
8
|
if (files.length === 0) {
|
|
17
9
|
this.log('No API route files found');
|
|
18
|
-
return;
|
|
10
|
+
return files;
|
|
19
11
|
}
|
|
20
12
|
this.log(`Found ${files.length} route file${files.length === 1 ? '' : 's'}:`);
|
|
21
13
|
this.log('');
|
|
22
14
|
for (const file of files) {
|
|
23
15
|
this.log(` ${file.filename}`);
|
|
24
16
|
}
|
|
17
|
+
return files;
|
|
25
18
|
}
|
|
26
19
|
}
|
|
@@ -40,7 +40,7 @@ export default class Publish extends Command {
|
|
|
40
40
|
this.log(`Publishing API routes from ${path}`);
|
|
41
41
|
const files = await loadFiles(path, {
|
|
42
42
|
extension: '.php',
|
|
43
|
-
onSkip: (message) => this.warn(message),
|
|
43
|
+
onSkip: (message) => { this.warn(message); },
|
|
44
44
|
parse: (raw, filePath) => ({ code: raw, filename: basename(filePath, '.php') }),
|
|
45
45
|
});
|
|
46
46
|
const api = new ApiClient(token);
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
type PullResult = {
|
|
3
|
+
orphans: string[];
|
|
4
|
+
pulled: string[];
|
|
5
|
+
status: 'dry-run' | 'success';
|
|
6
|
+
};
|
|
2
7
|
export default class Pull extends LoopressCommand {
|
|
3
8
|
static args: {
|
|
4
9
|
path: import("@oclif/core/interfaces").Arg<string | undefined, 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
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
16
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
17
|
};
|
|
12
|
-
run(): Promise<
|
|
18
|
+
run(): Promise<PullResult>;
|
|
13
19
|
}
|
|
20
|
+
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
3
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
5
|
import { LoopressCommand } from '../../lib/base.js';
|
|
6
6
|
import { basenameKey, findOrphanedFiles } from '../../lib/find-orphaned-files.js';
|
|
7
7
|
export default class Pull extends LoopressCommand {
|
|
@@ -9,6 +9,7 @@ export default class Pull extends LoopressCommand {
|
|
|
9
9
|
path: Args.string({ description: 'Path to api directory (overrides project config)' }),
|
|
10
10
|
};
|
|
11
11
|
static description = 'Pull custom API route files from WordPress';
|
|
12
|
+
static enableJsonFlag = true;
|
|
12
13
|
static examples = ['$ lps api pull', '$ lps api pull --path ./api'];
|
|
13
14
|
static flags = {
|
|
14
15
|
...LoopressCommand.dryRunFlag,
|
|
@@ -27,23 +28,31 @@ export default class Pull extends LoopressCommand {
|
|
|
27
28
|
const orphans = await findOrphanedFiles(path, new Set(files.map((file) => file.filename)), {
|
|
28
29
|
extensions: ['.php'],
|
|
29
30
|
key: basenameKey,
|
|
31
|
+
recursive: true,
|
|
30
32
|
});
|
|
33
|
+
const pulled = files.map((file) => file.filename);
|
|
31
34
|
if (this.dryRun) {
|
|
32
35
|
this.log(`[dry-run] Would pull ${files.length} route file${files.length === 1 ? '' : 's'} to ${path}`);
|
|
33
36
|
if (orphans.length > 0) {
|
|
34
37
|
this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} whose route no longer exists on WordPress: ${orphans.join(', ')}`);
|
|
35
38
|
}
|
|
36
|
-
return;
|
|
39
|
+
return { orphans, pulled, status: 'dry-run' };
|
|
37
40
|
}
|
|
38
41
|
await mkdir(path, { recursive: true });
|
|
39
42
|
await new Listr(files.map((file) => ({
|
|
40
43
|
async task(_ctx, task) {
|
|
41
|
-
|
|
44
|
+
const filePath = join(path, `${file.filename}.php`);
|
|
45
|
+
// filename can contain '/' (a path-param route, e.g. invoice-pdf/[order_id]):
|
|
46
|
+
// writeFile() doesn't create parent directories on its own the way mkdir()'s
|
|
47
|
+
// recursive option does above for the top-level path.
|
|
48
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
49
|
+
await writeFile(filePath, file.content);
|
|
42
50
|
task.output = `Pulled: ${file.filename}`;
|
|
43
51
|
},
|
|
44
52
|
title: `Pull ${file.filename}`,
|
|
45
|
-
}))).run();
|
|
53
|
+
})), { renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
|
|
46
54
|
await this.removeOrphanedFiles(path, orphans, 'whose route no longer exists on WordPress');
|
|
47
55
|
this.log(`Pulled ${files.length} route file${files.length === 1 ? '' : 's'} to ${path}`);
|
|
56
|
+
return { orphans, pulled, status: 'success' };
|
|
48
57
|
}
|
|
49
58
|
}
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
type PushResult = {
|
|
3
|
+
pushed: string[];
|
|
4
|
+
status: 'dry-run' | 'success';
|
|
5
|
+
};
|
|
2
6
|
export default class Push extends PushCommand {
|
|
3
7
|
static args: {
|
|
4
8
|
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
9
|
};
|
|
6
10
|
static description: string;
|
|
11
|
+
static enableJsonFlag: boolean;
|
|
7
12
|
static examples: string[];
|
|
8
13
|
static flags: {
|
|
9
14
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
15
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
16
|
};
|
|
12
|
-
run(): Promise<
|
|
17
|
+
run(): Promise<PushResult>;
|
|
13
18
|
private loadFiles;
|
|
14
19
|
private pushFile;
|
|
15
20
|
private syncApiRoutes;
|
|
16
21
|
}
|
|
22
|
+
export {};
|
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
|
-
import {
|
|
3
|
+
import { relative, sep } from 'node:path';
|
|
4
4
|
import { authManager } from '../../config/auth.manager.js';
|
|
5
5
|
import { ApiClient } from '../../lib/api-client.js';
|
|
6
6
|
import { loadFiles as loadDirectoryFiles } from '../../lib/load-files.js';
|
|
7
7
|
import { PushCommand } from '../../lib/push-command.js';
|
|
8
|
-
// Mirrors the server's own allowlist (wordpress-plugin ApiFilesController::
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
// Mirrors the server's own allowlist (wordpress-plugin ApiFilesController::isValidFilename()):
|
|
9
|
+
// filename is sent as a body param (not a URL path segment: a nested path can contain '/' and
|
|
10
|
+
// '[]', and depending on a server to handle a percent-encoded slash in a URL path correctly is
|
|
11
|
+
// exactly the kind of hosting-environment variance Loopress can't assume away), but validating
|
|
12
|
+
// client-side first still turns a malformed name into an accurate error and skips a network
|
|
13
|
+
// round-trip that could only ever fail.
|
|
14
|
+
const FILENAME_PATTERN = /^(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/;
|
|
15
|
+
// Mirrors the server's own check (wordpress-plugin FileWriter::DECLARE_PATTERN /
|
|
16
|
+
// withGuard()): the server rejects both an absent declare(strict_types=1); and one that
|
|
17
|
+
// appears more than once (it needs a single unambiguous insertion point for the ABSPATH
|
|
18
|
+
// guard). Matching that "exactly once" rule here too, not just presence, so a file that
|
|
19
|
+
// would still fail server-side doesn't falsely pass this earlier check.
|
|
20
|
+
const DECLARE_PATTERN = /declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;/g;
|
|
16
21
|
export default class Push extends PushCommand {
|
|
17
22
|
static args = {
|
|
18
23
|
path: Args.string({ description: 'Path to api directory (overrides project config)' }),
|
|
19
24
|
};
|
|
20
25
|
static description = 'Push custom API route files to WordPress';
|
|
26
|
+
static enableJsonFlag = true;
|
|
21
27
|
static examples = ['$ lps api push', '$ lps api push --path ./api'];
|
|
22
28
|
static flags = {
|
|
23
29
|
...PushCommand.dryRunFlag,
|
|
@@ -31,29 +37,46 @@ export default class Push extends PushCommand {
|
|
|
31
37
|
this.log(`API path: ${path}`);
|
|
32
38
|
const files = await this.loadFiles(path);
|
|
33
39
|
this.log(`Found ${files.length} route file${files.length === 1 ? '' : 's'} to push`);
|
|
40
|
+
const pushed = [];
|
|
34
41
|
await new Listr(files.map((file) => ({
|
|
35
|
-
task: async (_ctx, task) =>
|
|
42
|
+
task: async (_ctx, task) => {
|
|
43
|
+
await this.pushFile(file, task);
|
|
44
|
+
pushed.push(file.filename);
|
|
45
|
+
},
|
|
36
46
|
title: `Push ${file.filename}`,
|
|
37
|
-
})), { concurrent: false, exitOnError: false }).run();
|
|
47
|
+
})), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
|
|
38
48
|
if (this.failedCount > 0) {
|
|
39
49
|
this.error(`${this.failedCount} route file${this.failedCount === 1 ? '' : 's'} failed to push.`);
|
|
40
50
|
}
|
|
41
51
|
if (this.dryRun)
|
|
42
|
-
return;
|
|
52
|
+
return { pushed, status: 'dry-run' };
|
|
43
53
|
await this.recordSuccess();
|
|
44
54
|
await this.syncApiRoutes(files.map((file) => file.filename));
|
|
45
55
|
this.log('All API routes pushed.');
|
|
56
|
+
return { pushed, status: 'success' };
|
|
46
57
|
}
|
|
47
58
|
async loadFiles(path) {
|
|
48
59
|
return loadDirectoryFiles(path, {
|
|
49
60
|
extension: '.php',
|
|
50
|
-
onSkip: (message) => this.warn(message),
|
|
51
|
-
|
|
61
|
+
onSkip: (message) => { this.warn(message); },
|
|
62
|
+
// relative()'s separator is OS-specific ('\\' on Windows); the server only ever expects
|
|
63
|
+
// '/', same as any URL or import path.
|
|
64
|
+
parse: (raw, filePath) => ({
|
|
65
|
+
content: raw,
|
|
66
|
+
filename: relative(path, filePath).slice(0, -'.php'.length).split(sep).join('/'),
|
|
67
|
+
}),
|
|
68
|
+
recursive: true,
|
|
52
69
|
});
|
|
53
70
|
}
|
|
54
71
|
async pushFile(file, task) {
|
|
55
72
|
if (!FILENAME_PATTERN.test(file.filename)) {
|
|
56
|
-
const message = `Invalid filename "${file.filename}":
|
|
73
|
+
const message = `Invalid filename "${file.filename}": 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")`;
|
|
74
|
+
this.reportTaskFailure(message, new Error(message), task);
|
|
75
|
+
}
|
|
76
|
+
const declareCount = file.content.match(DECLARE_PATTERN)?.length ?? 0;
|
|
77
|
+
if (declareCount !== 1) {
|
|
78
|
+
const reason = declareCount === 0 ? 'is missing' : 'appears more than once';
|
|
79
|
+
const message = `${file.filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`;
|
|
57
80
|
this.reportTaskFailure(message, new Error(message), task);
|
|
58
81
|
}
|
|
59
82
|
if (this.dryRun) {
|
|
@@ -62,9 +85,16 @@ export default class Push extends PushCommand {
|
|
|
62
85
|
return;
|
|
63
86
|
}
|
|
64
87
|
try {
|
|
65
|
-
await this.wp.put(
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
const result = await this.wp.put('loopress/v1/api-files', {
|
|
89
|
+
content: file.content,
|
|
90
|
+
filename: file.filename,
|
|
91
|
+
});
|
|
92
|
+
if (task) {
|
|
93
|
+
task.output =
|
|
94
|
+
result.syntax_check === 'skipped'
|
|
95
|
+
? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
|
|
96
|
+
: `Pushed: ${file.filename}`;
|
|
97
|
+
}
|
|
68
98
|
}
|
|
69
99
|
catch (error) {
|
|
70
100
|
this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
|
|
@@ -27,12 +27,16 @@ export default class ComposerInit extends LoopressCommand {
|
|
|
27
27
|
this.log('composer.json already exists, not overwriting (non-interactive terminal).');
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
30
|
+
const isOverwrite = await confirm({ default: false, message: 'composer.json already exists. Overwrite?' });
|
|
31
|
+
if (!isOverwrite) {
|
|
32
32
|
this.log('Aborted.');
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
+
if (this.dryRun) {
|
|
37
|
+
this.log(`[dry-run] Would write composer.json to ${composerJsonPath}`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
36
40
|
const composerJson = {
|
|
37
41
|
// composer/installers is itself a Composer plugin, and Composer 2.2+ refuses to run any
|
|
38
42
|
// plugin that isn't explicitly trusted when running non-interactively (which every
|
|
@@ -46,10 +50,6 @@ export default class ComposerInit extends LoopressCommand {
|
|
|
46
50
|
[INSTALLERS_PACKAGE]: INSTALLERS_CONSTRAINT,
|
|
47
51
|
},
|
|
48
52
|
};
|
|
49
|
-
if (this.dryRun) {
|
|
50
|
-
this.log(`[dry-run] Would write composer.json to ${composerJsonPath}`);
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
53
|
await writeFile(composerJsonPath, JSON.stringify(composerJson, null, 2) + '\n', 'utf8');
|
|
54
54
|
this.log(`Wrote composer.json to ${composerJsonPath}`);
|
|
55
55
|
this.log('Add plugins with `wpackagist-plugin/<slug>` and a theme with `wpackagist-theme/<slug>` in require, then run `lps composer push`.');
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
type PullResult = {
|
|
3
|
+
status: 'dry-run' | 'success';
|
|
4
|
+
wroteLock: boolean;
|
|
5
|
+
};
|
|
2
6
|
export default class ComposerPull extends LoopressCommand {
|
|
3
7
|
static description: string;
|
|
8
|
+
static enableJsonFlag: boolean;
|
|
4
9
|
static examples: string[];
|
|
5
10
|
static flags: {
|
|
6
11
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
12
|
};
|
|
8
|
-
run(): Promise<
|
|
13
|
+
run(): Promise<PullResult>;
|
|
9
14
|
}
|
|
15
|
+
export {};
|
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
import { writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { LoopressCommand } from '../../lib/base.js';
|
|
4
|
+
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
5
|
+
// Narrower than isNotFoundError(): a bare 404 also covers the route being absent (plugin not
|
|
6
|
+
// installed, or an edition/version predating Composer support), which must still surface the
|
|
7
|
+
// normal "is the plugin installed?" guidance rather than being read as "no lock yet".
|
|
8
|
+
function isMissingComposerLock(error) {
|
|
9
|
+
if (!isNotFoundError(error))
|
|
10
|
+
return false;
|
|
11
|
+
const body = error.cause?.response?.body;
|
|
12
|
+
if (!body)
|
|
13
|
+
return false;
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(body);
|
|
16
|
+
return parsed.error === 'composer.lock not found';
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
4
22
|
export default class ComposerPull extends LoopressCommand {
|
|
5
23
|
static description = 'Pull composer.json and composer.lock from WordPress';
|
|
24
|
+
static enableJsonFlag = true;
|
|
6
25
|
static examples = ['$ lps composer pull', '$ lps composer pull --dry-run'];
|
|
7
26
|
static flags = {
|
|
8
27
|
...LoopressCommand.dryRunFlag,
|
|
@@ -11,15 +30,33 @@ export default class ComposerPull extends LoopressCommand {
|
|
|
11
30
|
const { url } = this.siteConfig;
|
|
12
31
|
this.log(`Pulling composer.json and composer.lock from ${url}`);
|
|
13
32
|
const { composerJson } = await this.wp.get('loopress/v1/composer/json');
|
|
14
|
-
|
|
33
|
+
// A site that never had Composer dependencies pushed has no composer.lock yet: that's a
|
|
34
|
+
// legitimate applicative 404 from the Loopress controller (ComposerController::get_lock(),
|
|
35
|
+
// body `{"error": "composer.lock not found"}`), not a sign anything is broken. Any other
|
|
36
|
+
// 404 (route absent because the plugin isn't installed or predates Composer support) must
|
|
37
|
+
// still surface the normal "is the plugin installed?" guidance instead of being swallowed.
|
|
38
|
+
let composerLock;
|
|
39
|
+
try {
|
|
40
|
+
;
|
|
41
|
+
({ composerLock } = await this.wp.get('loopress/v1/composer/lock'));
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (!isMissingComposerLock(error))
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
15
47
|
if (this.dryRun) {
|
|
16
|
-
this.log('[dry-run] Would write composer.json and composer.lock');
|
|
17
|
-
return;
|
|
48
|
+
this.log(composerLock ? '[dry-run] Would write composer.json and composer.lock' : '[dry-run] Would write composer.json');
|
|
49
|
+
return { status: 'dry-run', wroteLock: composerLock !== undefined };
|
|
18
50
|
}
|
|
19
51
|
const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
|
|
20
|
-
const lockPath = join(process.cwd(), this.rootDir, 'composer.lock');
|
|
21
52
|
await writeFile(composerJsonPath, composerJson, 'utf8');
|
|
53
|
+
if (composerLock === undefined) {
|
|
54
|
+
this.log('Wrote composer.json (no composer.lock on this site yet)');
|
|
55
|
+
return { status: 'success', wroteLock: false };
|
|
56
|
+
}
|
|
57
|
+
const lockPath = join(process.cwd(), this.rootDir, 'composer.lock');
|
|
22
58
|
await writeFile(lockPath, composerLock, 'utf8');
|
|
23
59
|
this.log(`Wrote composer.json and composer.lock`);
|
|
60
|
+
return { status: 'success', wroteLock: true };
|
|
24
61
|
}
|
|
25
62
|
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
type PushResult = {
|
|
3
|
+
hasLock: boolean;
|
|
4
|
+
packageCount: number;
|
|
5
|
+
status: 'dry-run' | 'success';
|
|
6
|
+
};
|
|
2
7
|
export default class ComposerPush extends PushCommand {
|
|
3
8
|
static description: string;
|
|
9
|
+
static enableJsonFlag: boolean;
|
|
4
10
|
static examples: string[];
|
|
5
11
|
static flags: {
|
|
6
12
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
13
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
14
|
};
|
|
9
|
-
run(): Promise<
|
|
15
|
+
run(): Promise<PushResult>;
|
|
10
16
|
}
|
|
17
|
+
export {};
|
|
@@ -7,6 +7,7 @@ import { isTimeoutError } from '../../lib/wp-client.js';
|
|
|
7
7
|
const COMPOSER_SYNC_TIMEOUT_MS = 600_000;
|
|
8
8
|
export default class ComposerPush extends PushCommand {
|
|
9
9
|
static description = 'Push composer.json and composer.lock to WordPress and run composer install';
|
|
10
|
+
static enableJsonFlag = true;
|
|
10
11
|
static examples = ['$ lps composer push', '$ lps composer push --dry-run'];
|
|
11
12
|
static flags = {
|
|
12
13
|
...PushCommand.dryRunFlag,
|
|
@@ -32,7 +33,7 @@ export default class ComposerPush extends PushCommand {
|
|
|
32
33
|
this.warn('No composer.lock found. The server will resolve versions freely.');
|
|
33
34
|
}
|
|
34
35
|
if (this.dryRun)
|
|
35
|
-
return;
|
|
36
|
+
return { hasLock, packageCount, status: 'dry-run' };
|
|
36
37
|
this.log('Running composer install on the server, this can take a few minutes...');
|
|
37
38
|
try {
|
|
38
39
|
await this.wp.post('loopress/v1/composer/sync', {
|
|
@@ -48,5 +49,6 @@ export default class ComposerPush extends PushCommand {
|
|
|
48
49
|
}
|
|
49
50
|
this.log('composer install completed on the server.');
|
|
50
51
|
await this.recordSuccess();
|
|
52
|
+
return { hasLock, packageCount, status: 'success' };
|
|
51
53
|
}
|
|
52
54
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class Dev extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
only: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
skip: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
};
|
|
9
|
+
run(): Promise<void>;
|
|
10
|
+
private parseTypeFlag;
|
|
11
|
+
private pushBatch;
|
|
12
|
+
private watch;
|
|
13
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { watch as watchFiles } from 'chokidar';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { configManager } from '../config/project-config.manager.js';
|
|
5
|
+
import { buildWatchTargets, createDebouncedBatcher, resolveResourceTypes, RESOURCE_TYPES, resourceTypeForPath } from '../lib/dev-watch.js';
|
|
6
|
+
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
7
|
+
const DEBOUNCE_MS = 400;
|
|
8
|
+
// Noise no one wants pushed: VCS internals, deps, and editor/OS droppings.
|
|
9
|
+
const IGNORED = /(^|[/\\])(\.git|node_modules|\.DS_Store)(?:[/\\]|$)|\.swp$/;
|
|
10
|
+
export default class Dev extends Command {
|
|
11
|
+
static description = 'Watch project files and push changes to the local WordPress instance as they happen. Always targets the "local" environment, run `lps snippet push` etc. directly for any other environment.';
|
|
12
|
+
static examples = ['$ lps dev', '$ lps dev --only=snippets,pages', '$ lps dev --skip=plugins'];
|
|
13
|
+
static flags = {
|
|
14
|
+
only: Flags.string({ description: `Only watch these resource types (comma-separated): ${RESOURCE_TYPES.join(', ')}` }),
|
|
15
|
+
skip: Flags.string({ description: `Skip these resource types (comma-separated): ${RESOURCE_TYPES.join(', ')}` }),
|
|
16
|
+
};
|
|
17
|
+
async run() {
|
|
18
|
+
const { flags } = await this.parse(Dev);
|
|
19
|
+
const only = flags.only ? this.parseTypeFlag(flags.only, 'only') : undefined;
|
|
20
|
+
const skip = flags.skip ? this.parseTypeFlag(flags.skip, 'skip') : [];
|
|
21
|
+
const types = resolveResourceTypes(only, skip);
|
|
22
|
+
const localConfig = await readLocalConfig();
|
|
23
|
+
const projectId = localConfig.projectId ?? configManager.getCurrentProject()?.id;
|
|
24
|
+
if (!projectId)
|
|
25
|
+
this.error('No project configured. Run `lps project config` first.');
|
|
26
|
+
if (!configManager.getEnvironment(projectId, 'local')) {
|
|
27
|
+
this.error('No "local" environment configured for this project. Run `lps project config` and add a "local" environment.');
|
|
28
|
+
}
|
|
29
|
+
const targets = buildWatchTargets(types, localConfig, process.cwd());
|
|
30
|
+
if (targets.length === 0) {
|
|
31
|
+
this.error('Nothing to watch: none of the selected resource directories exist yet.');
|
|
32
|
+
}
|
|
33
|
+
for (const target of targets)
|
|
34
|
+
this.log(`Watching ${target.type}: ${target.path}`);
|
|
35
|
+
await this.watch(targets);
|
|
36
|
+
}
|
|
37
|
+
parseTypeFlag(raw, flagName) {
|
|
38
|
+
return raw
|
|
39
|
+
.split(',')
|
|
40
|
+
.map((value) => value.trim())
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.map((value) => {
|
|
43
|
+
if (!RESOURCE_TYPES.includes(value)) {
|
|
44
|
+
this.error(`Unknown resource type "${value}" in --${flagName}. Valid types: ${RESOURCE_TYPES.join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// Sequential on purpose (see notes technique: parallelizing push is out of scope). A failed
|
|
50
|
+
// push is logged and swallowed here, never thrown, so one bad batch can't take the watcher down.
|
|
51
|
+
async pushBatch(changesByType, targets) {
|
|
52
|
+
for (const [type, paths] of changesByType) {
|
|
53
|
+
const target = targets.find((t) => t.type === type);
|
|
54
|
+
if (!target)
|
|
55
|
+
continue;
|
|
56
|
+
this.log(`\n→ ${type} changed (${paths.map((path) => basename(path)).join(', ')}), pushing to local...`);
|
|
57
|
+
try {
|
|
58
|
+
await this.config.runCommand(target.commandId, ['--env', 'local']);
|
|
59
|
+
this.log(`✓ ${type} synced`);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
this.log(`✗ ${type} failed: ${error.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async watch(targets) {
|
|
67
|
+
const batcher = createDebouncedBatcher(async (paths) => {
|
|
68
|
+
const changesByType = new Map();
|
|
69
|
+
for (const path of paths) {
|
|
70
|
+
const type = resourceTypeForPath(path, targets);
|
|
71
|
+
if (!type)
|
|
72
|
+
continue;
|
|
73
|
+
changesByType.set(type, [...(changesByType.get(type) ?? []), path]);
|
|
74
|
+
}
|
|
75
|
+
await this.pushBatch(changesByType, targets);
|
|
76
|
+
}, DEBOUNCE_MS);
|
|
77
|
+
const watcher = watchFiles(targets.map((target) => target.path), {
|
|
78
|
+
awaitWriteFinish: { pollInterval: 100, stabilityThreshold: 300 },
|
|
79
|
+
ignoreInitial: true,
|
|
80
|
+
ignored: (path) => IGNORED.test(path),
|
|
81
|
+
});
|
|
82
|
+
watcher
|
|
83
|
+
.on('add', batcher.queue)
|
|
84
|
+
.on('change', batcher.queue)
|
|
85
|
+
.on('unlink', (filePath) => { this.log(`⚠ ${filePath} deleted locally, not synced automatically`); });
|
|
86
|
+
this.log('\nWatching for changes. Press Ctrl+C to stop.\n');
|
|
87
|
+
await new Promise((resolve) => {
|
|
88
|
+
process.once('SIGINT', () => { resolve(); });
|
|
89
|
+
});
|
|
90
|
+
batcher.cancel();
|
|
91
|
+
await watcher.close();
|
|
92
|
+
// A push failure mid-session sets process.exitCode; stopping the watcher on purpose is a
|
|
93
|
+
// clean exit regardless of that history (see AC: no meaningful exit code needed for `dev`).
|
|
94
|
+
process.exitCode = 0;
|
|
95
|
+
this.log('\nStopped watching.');
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -15,12 +15,12 @@ export default class Doctor extends LoopressCommand {
|
|
|
15
15
|
this.log(`URL: ${url}`);
|
|
16
16
|
this.log('');
|
|
17
17
|
let failed = 0;
|
|
18
|
-
const
|
|
18
|
+
const isReachable = await this.check('WordPress REST API reachable', async () => {
|
|
19
19
|
const diagnostic = await diagnoseWpSite(url);
|
|
20
20
|
if (!diagnostic.ok)
|
|
21
21
|
throw new Error(diagnostic.reason);
|
|
22
22
|
});
|
|
23
|
-
if (!
|
|
23
|
+
if (!isReachable) {
|
|
24
24
|
failed++;
|
|
25
25
|
this.log('- Remaining checks skipped while the site is unreachable.');
|
|
26
26
|
this.error('1 check failed.', { exit: 1 });
|
|
@@ -34,12 +34,12 @@ export default class Doctor extends LoopressCommand {
|
|
|
34
34
|
// The namespace index answers regardless of which Loopress features are active, so a 404
|
|
35
35
|
// here can only mean the plugin itself is missing or outdated (WpClient's 404 message
|
|
36
36
|
// already says exactly that).
|
|
37
|
-
if (!(await this.check('Loopress plugin installed (loopress/v1 endpoints)', () => this.wp.get('loopress/v1')))) {
|
|
37
|
+
if (!(await this.check('Loopress plugin installed (loopress/v1 endpoints)', async () => this.wp.get('loopress/v1')))) {
|
|
38
38
|
failed++;
|
|
39
39
|
}
|
|
40
40
|
// wp/v2/users/me is WordPress core and requires authentication, so it validates the
|
|
41
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')))) {
|
|
42
|
+
if (!(await this.check('Credentials accepted (authenticated request)', async () => this.wp.get('wp/v2/users/me')))) {
|
|
43
43
|
failed++;
|
|
44
44
|
}
|
|
45
45
|
await this.reportPluginVersion();
|
package/dist/commands/init.js
CHANGED
|
@@ -29,11 +29,11 @@ export default class Init extends Command {
|
|
|
29
29
|
}
|
|
30
30
|
const configPath = join(process.cwd(), 'loopress.json');
|
|
31
31
|
if (existsSync(configPath)) {
|
|
32
|
-
const
|
|
32
|
+
const isOverwrite = await confirm({
|
|
33
33
|
default: false,
|
|
34
34
|
message: 'loopress.json already exists. Overwrite?',
|
|
35
35
|
});
|
|
36
|
-
if (!
|
|
36
|
+
if (!isOverwrite) {
|
|
37
37
|
this.log('Aborted.');
|
|
38
38
|
return;
|
|
39
39
|
}
|
|
@@ -65,11 +65,11 @@ export default class Init extends Command {
|
|
|
65
65
|
config[feature.key] = feature.dir;
|
|
66
66
|
}
|
|
67
67
|
await writeLocalConfig(config);
|
|
68
|
-
let
|
|
68
|
+
let isProviderAdded = false;
|
|
69
69
|
if (providerChoice !== '__none__') {
|
|
70
70
|
try {
|
|
71
71
|
await this.config.runCommand('plugin:add', [providerChoice]);
|
|
72
|
-
|
|
72
|
+
isProviderAdded = true;
|
|
73
73
|
}
|
|
74
74
|
catch (error) {
|
|
75
75
|
this.warn(error.message);
|
|
@@ -83,7 +83,7 @@ export default class Init extends Command {
|
|
|
83
83
|
this.log(` ${feature.label}:${' '.repeat(Math.max(1, 9 - feature.label.length))}${join(rootDir, feature.dir)}`);
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
-
if (
|
|
86
|
+
if (isProviderAdded) {
|
|
87
87
|
this.log(` Plugin: ${providerChoice}`);
|
|
88
88
|
}
|
|
89
89
|
this.log('\n→ Next: run `lps snippet pull` to fetch what is already on the site, or `lps doctor` to verify the connection.');
|
|
@@ -102,8 +102,8 @@ export default class Init extends Command {
|
|
|
102
102
|
let projects = configManager.listProjects();
|
|
103
103
|
if (projects.length === 0) {
|
|
104
104
|
this.log('No projects configured yet.');
|
|
105
|
-
const
|
|
106
|
-
if (!
|
|
105
|
+
const isRunConfig = await confirm({ default: true, message: 'Run `lps project config` now to add one?' });
|
|
106
|
+
if (!isRunConfig) {
|
|
107
107
|
return this.promptManualProjectId();
|
|
108
108
|
}
|
|
109
109
|
await this.config.runCommand('project:config');
|
package/dist/commands/login.js
CHANGED
|
@@ -10,19 +10,19 @@ export default class Login extends Command {
|
|
|
10
10
|
authManager.setAuth({ email, savedAt: new Date().toISOString(), token });
|
|
11
11
|
this.log(`\nLogged in${email ? ` as ${email}` : ''}. You're all set!`);
|
|
12
12
|
}
|
|
13
|
-
waitForCallback() {
|
|
13
|
+
async waitForCallback() {
|
|
14
14
|
return waitForLocalCallback({
|
|
15
15
|
buildUrl: (callbackBaseUrl) => `${CONSOLE_URL}/cli-auth?callbackUrl=${encodeURIComponent(`${callbackBaseUrl}/callback`)}`,
|
|
16
16
|
handleRequest(url, { resolveWithPage, respondBadRequest }) {
|
|
17
17
|
const token = url.searchParams.get('token');
|
|
18
|
-
const email = url.searchParams.get('email') ?? undefined;
|
|
19
18
|
if (!token) {
|
|
20
19
|
respondBadRequest('Missing token');
|
|
21
20
|
return;
|
|
22
21
|
}
|
|
22
|
+
const email = url.searchParams.get('email') ?? undefined;
|
|
23
23
|
resolveWithPage(SUCCESS_PAGE, { email, token });
|
|
24
24
|
},
|
|
25
|
-
log: (message) => this.log(message),
|
|
25
|
+
log: (message) => { this.log(message); },
|
|
26
26
|
openingMessage: 'Opening Loopress console in your browser...',
|
|
27
27
|
timeoutMessage: 'Login timed out after 5 minutes. Please try again.',
|
|
28
28
|
});
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
2
|
export default class List extends LoopressCommand {
|
|
3
3
|
static description: string;
|
|
4
|
+
static enableJsonFlag: boolean;
|
|
4
5
|
static examples: string[];
|
|
5
|
-
|
|
6
|
-
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
-
};
|
|
8
|
-
run(): Promise<void>;
|
|
6
|
+
run(): Promise<Array<Record<string, unknown>>>;
|
|
9
7
|
}
|