@loopress/cli 0.22.0 → 0.24.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 +794 -85
- package/dist/commands/acf/diff.d.ts +2 -0
- package/dist/commands/acf/diff.js +5 -0
- package/dist/commands/api/diff.d.ts +2 -0
- package/dist/commands/api/diff.js +5 -0
- package/dist/commands/api/list.d.ts +2 -12
- package/dist/commands/api/list.js +2 -20
- package/dist/commands/api/pull.d.ts +2 -20
- package/dist/commands/api/pull.js +2 -53
- package/dist/commands/api/push.d.ts +2 -22
- package/dist/commands/api/push.js +2 -116
- package/dist/commands/app/list.d.ts +17 -0
- package/dist/commands/app/list.js +35 -0
- package/dist/commands/{page → app}/pull.d.ts +7 -5
- package/dist/commands/app/pull.js +94 -0
- package/dist/commands/app/push.d.ts +28 -0
- package/dist/commands/app/push.js +130 -0
- package/dist/commands/app/remove.d.ts +20 -0
- package/dist/commands/app/remove.js +42 -0
- package/dist/commands/composer/pull.js +6 -16
- package/dist/commands/composer/push.d.ts +1 -0
- package/dist/commands/composer/push.js +43 -19
- package/dist/commands/dev.js +1 -1
- package/dist/commands/diff.d.ts +13 -0
- package/dist/commands/diff.js +42 -0
- package/dist/commands/form/diff.d.ts +2 -0
- package/dist/commands/form/diff.js +5 -0
- package/dist/commands/hook/diff.d.ts +2 -0
- package/dist/commands/hook/diff.js +5 -0
- package/dist/commands/hook/list.d.ts +2 -0
- package/dist/commands/hook/list.js +2 -0
- package/dist/commands/hook/pull.d.ts +2 -0
- package/dist/commands/hook/pull.js +2 -0
- package/dist/commands/hook/push.d.ts +2 -0
- package/dist/commands/hook/push.js +2 -0
- package/dist/commands/init.js +2 -1
- package/dist/commands/option/add.d.ts +15 -0
- package/dist/commands/option/add.js +47 -0
- package/dist/commands/option/diff.d.ts +2 -0
- package/dist/commands/option/diff.js +5 -0
- package/dist/commands/{page → option}/list.d.ts +5 -1
- package/dist/commands/option/list.js +151 -0
- package/dist/commands/option/pull.d.ts +16 -0
- package/dist/commands/option/pull.js +75 -0
- package/dist/commands/{page → option}/push.d.ts +3 -8
- package/dist/commands/option/push.js +66 -0
- package/dist/commands/option/remove.d.ts +22 -0
- package/dist/commands/option/remove.js +69 -0
- package/dist/commands/plugin/add.d.ts +1 -0
- package/dist/commands/plugin/add.js +18 -8
- package/dist/commands/plugin/audit.d.ts +30 -0
- package/dist/commands/plugin/audit.js +138 -0
- package/dist/commands/plugin/pull.d.ts +1 -2
- package/dist/commands/plugin/pull.js +11 -11
- package/dist/commands/plugin/push.d.ts +13 -8
- package/dist/commands/plugin/push.js +161 -67
- package/dist/commands/plugin/status.d.ts +22 -0
- package/dist/commands/plugin/status.js +65 -0
- package/dist/commands/pull.js +3 -2
- package/dist/commands/push.js +4 -3
- package/dist/commands/seo/diff.d.ts +2 -0
- package/dist/commands/seo/diff.js +5 -0
- package/dist/commands/snippet/diff.d.ts +2 -0
- package/dist/commands/snippet/diff.js +5 -0
- package/dist/commands/theme/add.d.ts +13 -0
- package/dist/commands/theme/add.js +35 -0
- package/dist/commands/theme/pull.d.ts +21 -0
- package/dist/commands/theme/pull.js +37 -0
- package/dist/commands/theme/push.d.ts +22 -0
- package/dist/commands/theme/push.js +123 -0
- package/dist/commands/theme/status.d.ts +20 -0
- package/dist/commands/theme/status.js +51 -0
- package/dist/lib/app-manifest.d.ts +36 -0
- package/dist/lib/app-manifest.js +162 -0
- package/dist/lib/base.d.ts +3 -1
- package/dist/lib/base.js +14 -19
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +1 -2
- package/dist/lib/diff-command.d.ts +47 -0
- package/dist/lib/diff-command.js +180 -0
- package/dist/lib/diff-state.d.ts +22 -0
- package/dist/lib/diff-state.js +88 -0
- package/dist/lib/find-orphaned-files.js +1 -1
- package/dist/lib/interactive.d.ts +1 -0
- package/dist/lib/interactive.js +10 -0
- package/dist/lib/php-files-command.d.ts +54 -0
- package/dist/lib/php-files-command.js +216 -0
- package/dist/lib/put-or-create.js +1 -1
- package/dist/lib/readdir-tolerant.d.ts +1 -0
- package/dist/lib/readdir-tolerant.js +3 -1
- package/dist/lib/resource-state.d.ts +14 -0
- package/dist/lib/resource-state.js +361 -0
- package/dist/lib/validate-local.js +15 -7
- package/dist/lib/wp-client.d.ts +5 -0
- package/dist/lib/wp-client.js +56 -0
- package/dist/types/plugin.d.ts +1 -0
- package/dist/types/project-config.generated.d.ts +19 -5
- package/dist/utils/option-format.d.ts +31 -0
- package/dist/utils/option-format.js +51 -0
- package/dist/utils/plugin-sync.d.ts +26 -0
- package/dist/utils/plugin-sync.js +31 -0
- package/dist/utils/plugins.d.ts +15 -2
- package/dist/utils/plugins.js +57 -12
- package/dist/utils/resource-dirs.d.ts +13 -0
- package/dist/utils/resource-dirs.js +34 -0
- package/dist/utils/themes.d.ts +30 -0
- package/dist/utils/themes.js +29 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/version.js +21 -0
- package/oclif.manifest.json +1897 -244
- package/package.json +26 -19
- package/schemas/project-config.schema.json +32 -9
- package/dist/commands/page/list.js +0 -19
- package/dist/commands/page/pull.js +0 -57
- package/dist/commands/page/push.js +0 -132
- package/dist/utils/page-format.d.ts +0 -7
- package/dist/utils/page-format.js +0 -78
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { resourceDiffCommand } from '../../lib/diff-command.js';
|
|
2
|
+
export default resourceDiffCommand('acf', {
|
|
3
|
+
description: 'Show what differs in ACF field groups, post types, taxonomies, and options pages between your local files and a WordPress environment, or between two environments',
|
|
4
|
+
pathNoun: 'ACF directory',
|
|
5
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { resourceDiffCommand } from '../../lib/diff-command.js';
|
|
2
|
+
export default resourceDiffCommand('api', {
|
|
3
|
+
description: 'Show what differs in custom API route files between your local files and a WordPress environment, or between two environments',
|
|
4
|
+
pathNoun: 'api directory',
|
|
5
|
+
});
|
|
@@ -1,12 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
content: string;
|
|
4
|
-
filename: string;
|
|
5
|
-
};
|
|
6
|
-
export default class List extends LoopressCommand {
|
|
7
|
-
static description: string;
|
|
8
|
-
static enableJsonFlag: boolean;
|
|
9
|
-
static examples: string[];
|
|
10
|
-
run(): Promise<ApiFile[]>;
|
|
11
|
-
}
|
|
12
|
-
export {};
|
|
1
|
+
declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").ListFilesCommand>;
|
|
2
|
+
export default _default;
|
|
@@ -1,20 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
export default class List extends LoopressCommand {
|
|
4
|
-
static description = 'List custom API route files from WordPress';
|
|
5
|
-
static enableJsonFlag = true;
|
|
6
|
-
static examples = ['$ lps api list'];
|
|
7
|
-
async run() {
|
|
8
|
-
const files = await this.wp.get('loopress/v1/api-files');
|
|
9
|
-
if (files.length === 0) {
|
|
10
|
-
this.log('No API route files found');
|
|
11
|
-
return files;
|
|
12
|
-
}
|
|
13
|
-
this.log(`Found ${pluralize(files.length, 'route file')}:`);
|
|
14
|
-
this.log('');
|
|
15
|
-
for (const file of files) {
|
|
16
|
-
this.log(` ${file.filename}`);
|
|
17
|
-
}
|
|
18
|
-
return files;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
1
|
+
import { API_FILES_RESOURCE, resourceListCommand } from '../../lib/php-files-command.js';
|
|
2
|
+
export default resourceListCommand(API_FILES_RESOURCE);
|
|
@@ -1,20 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
orphans: string[];
|
|
4
|
-
pulled: string[];
|
|
5
|
-
status: 'dry-run' | 'success';
|
|
6
|
-
};
|
|
7
|
-
export default class Pull extends LoopressCommand {
|
|
8
|
-
static args: {
|
|
9
|
-
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
10
|
-
};
|
|
11
|
-
static description: string;
|
|
12
|
-
static enableJsonFlag: boolean;
|
|
13
|
-
static examples: string[];
|
|
14
|
-
static flags: {
|
|
15
|
-
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
-
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
-
};
|
|
18
|
-
run(): Promise<PullResult>;
|
|
19
|
-
}
|
|
20
|
-
export {};
|
|
1
|
+
declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").PullFilesCommand>;
|
|
2
|
+
export default _default;
|
|
@@ -1,53 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
4
|
-
import { LoopressCommand } from '../../lib/base.js';
|
|
5
|
-
import { basenameKey, findOrphanedFiles } from '../../lib/find-orphaned-files.js';
|
|
6
|
-
import { pluralize } from '../../utils/pluralize.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 enableJsonFlag = true;
|
|
13
|
-
static examples = ['$ lps api pull', '$ lps api pull --path ./api'];
|
|
14
|
-
static flags = {
|
|
15
|
-
...LoopressCommand.dryRunFlag,
|
|
16
|
-
...LoopressCommand.yesFlag,
|
|
17
|
-
};
|
|
18
|
-
async run() {
|
|
19
|
-
const { args } = await this.parse(Pull);
|
|
20
|
-
const { url } = this.siteConfig;
|
|
21
|
-
const path = this.resolveApiPath(args.path);
|
|
22
|
-
this.log(`Pulling API routes from ${url}`);
|
|
23
|
-
this.log(`API path: ${path}`);
|
|
24
|
-
const files = await this.wp.get('loopress/v1/api-files');
|
|
25
|
-
// A `<filename>.php` no longer present remotely belongs to a route deleted on WordPress
|
|
26
|
-
// (push itself stays additive-only, but pull already cleans up locally, same as
|
|
27
|
-
// `snippet pull`).
|
|
28
|
-
const orphans = await findOrphanedFiles(path, new Set(files.map((file) => file.filename)), {
|
|
29
|
-
extensions: ['.php'],
|
|
30
|
-
key: basenameKey,
|
|
31
|
-
recursive: true,
|
|
32
|
-
});
|
|
33
|
-
const pulled = files.map((file) => file.filename);
|
|
34
|
-
await this.pullDirectory(path, files, orphans, {
|
|
35
|
-
alwaysCreateDir: true,
|
|
36
|
-
dryRunMessage: `Would pull ${pluralize(files.length, 'route file')} to ${path}`,
|
|
37
|
-
orphanReason: 'whose route no longer exists on WordPress',
|
|
38
|
-
pulledMessage: `Pulled ${pluralize(files.length, 'route file')} to ${path}`,
|
|
39
|
-
title: (file) => file.filename,
|
|
40
|
-
async write(file, writeDir) {
|
|
41
|
-
const filePath = join(writeDir, `${file.filename}.php`);
|
|
42
|
-
// filename can contain '/' (a path-param route, e.g. invoice-pdf/[order_id]):
|
|
43
|
-
// writeFile() doesn't create parent directories on its own the way mkdir()'s
|
|
44
|
-
// recursive option does above for the top-level path.
|
|
45
|
-
await mkdir(dirname(filePath), { recursive: true });
|
|
46
|
-
await writeFile(filePath, file.content);
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
if (this.dryRun)
|
|
50
|
-
return { orphans, pulled, status: 'dry-run' };
|
|
51
|
-
return { orphans, pulled, status: 'success' };
|
|
52
|
-
}
|
|
53
|
-
}
|
|
1
|
+
import { API_FILES_RESOURCE, resourcePullCommand } from '../../lib/php-files-command.js';
|
|
2
|
+
export default resourcePullCommand(API_FILES_RESOURCE);
|
|
@@ -1,22 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
pushed: string[];
|
|
4
|
-
status: 'dry-run' | 'success';
|
|
5
|
-
};
|
|
6
|
-
export default class Push extends PushCommand {
|
|
7
|
-
static args: {
|
|
8
|
-
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
9
|
-
};
|
|
10
|
-
static description: string;
|
|
11
|
-
static enableJsonFlag: boolean;
|
|
12
|
-
static examples: string[];
|
|
13
|
-
static flags: {
|
|
14
|
-
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
-
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
-
};
|
|
17
|
-
run(): Promise<PushResult>;
|
|
18
|
-
private loadFiles;
|
|
19
|
-
private pushFile;
|
|
20
|
-
private syncApiRoutes;
|
|
21
|
-
}
|
|
22
|
-
export {};
|
|
1
|
+
declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").PushFilesCommand>;
|
|
2
|
+
export default _default;
|
|
@@ -1,116 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { authManager } from '../../config/auth.manager.js';
|
|
4
|
-
import { ApiClient } from '../../lib/api-client.js';
|
|
5
|
-
import { loadFiles as loadDirectoryFiles } from '../../lib/load-files.js';
|
|
6
|
-
import { PushCommand } from '../../lib/push-command.js';
|
|
7
|
-
import { pluralize } from '../../utils/pluralize.js';
|
|
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;
|
|
21
|
-
export default class Push extends PushCommand {
|
|
22
|
-
static args = {
|
|
23
|
-
path: Args.string({ description: 'Path to api directory (overrides project config)' }),
|
|
24
|
-
};
|
|
25
|
-
static description = 'Push custom API route files to WordPress';
|
|
26
|
-
static enableJsonFlag = true;
|
|
27
|
-
static examples = ['$ lps api push', '$ lps api push --path ./api'];
|
|
28
|
-
static flags = {
|
|
29
|
-
...PushCommand.dryRunFlag,
|
|
30
|
-
...PushCommand.yesFlag,
|
|
31
|
-
};
|
|
32
|
-
async run() {
|
|
33
|
-
const { args } = await this.parse(Push);
|
|
34
|
-
const { url } = this.siteConfig;
|
|
35
|
-
const path = this.resolveApiPath(args.path);
|
|
36
|
-
this.log(`Pushing API routes to ${url}`);
|
|
37
|
-
this.log(`API path: ${path}`);
|
|
38
|
-
const files = await this.loadFiles(path);
|
|
39
|
-
this.log(`Found ${pluralize(files.length, 'route file')} to push`);
|
|
40
|
-
const pushed = [];
|
|
41
|
-
await this.runPushTasks(files, (file) => file.filename, async (file, task) => {
|
|
42
|
-
await this.pushFile(file, task);
|
|
43
|
-
pushed.push(file.filename);
|
|
44
|
-
});
|
|
45
|
-
if (this.failedCount > 0) {
|
|
46
|
-
this.error(`${pluralize(this.failedCount, 'route file')} failed to push.`);
|
|
47
|
-
}
|
|
48
|
-
if (this.dryRun)
|
|
49
|
-
return { pushed, status: 'dry-run' };
|
|
50
|
-
await this.recordSuccess();
|
|
51
|
-
await this.syncApiRoutes(files.map((file) => file.filename));
|
|
52
|
-
this.log('All API routes pushed.');
|
|
53
|
-
return { pushed, status: 'success' };
|
|
54
|
-
}
|
|
55
|
-
async loadFiles(path) {
|
|
56
|
-
return loadDirectoryFiles(path, {
|
|
57
|
-
extension: '.php',
|
|
58
|
-
onSkip: (message) => { this.warn(message); },
|
|
59
|
-
// relative()'s separator is OS-specific ('\\' on Windows); the server only ever expects
|
|
60
|
-
// '/', same as any URL or import path.
|
|
61
|
-
parse: (raw, filePath) => ({
|
|
62
|
-
content: raw,
|
|
63
|
-
filename: relative(path, filePath).slice(0, -'.php'.length).split(sep).join('/'),
|
|
64
|
-
}),
|
|
65
|
-
recursive: true,
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
async pushFile(file, task) {
|
|
69
|
-
if (!FILENAME_PATTERN.test(file.filename)) {
|
|
70
|
-
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")`;
|
|
71
|
-
this.reportTaskFailure(message, new Error(message), task);
|
|
72
|
-
}
|
|
73
|
-
const declareCount = file.content.match(DECLARE_PATTERN)?.length ?? 0;
|
|
74
|
-
if (declareCount !== 1) {
|
|
75
|
-
const reason = declareCount === 0 ? 'is missing' : 'appears more than once';
|
|
76
|
-
const message = `${file.filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`;
|
|
77
|
-
this.reportTaskFailure(message, new Error(message), task);
|
|
78
|
-
}
|
|
79
|
-
if (this.dryRun) {
|
|
80
|
-
if (task)
|
|
81
|
-
task.output = `[dry-run] Would push: ${file.filename}`;
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
try {
|
|
85
|
-
const result = await this.wp.put('loopress/v1/api-files', {
|
|
86
|
-
content: file.content,
|
|
87
|
-
filename: file.filename,
|
|
88
|
-
});
|
|
89
|
-
if (task) {
|
|
90
|
-
task.output =
|
|
91
|
-
result.syntax_check === 'skipped'
|
|
92
|
-
? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
|
|
93
|
-
: `Pushed: ${file.filename}`;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
catch (error) {
|
|
97
|
-
this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
// Best-effort report of the current file list to the Loopress cloud, purely for console
|
|
101
|
-
// visibility (see obsidian/Product/API and Console Backlog.md, US-18): the routes are already
|
|
102
|
-
// live on WordPress by this point, this can never block or fail the push itself, same
|
|
103
|
-
// reasoning as `recordDeployment` in PushCommand.
|
|
104
|
-
async syncApiRoutes(filenames) {
|
|
105
|
-
const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token;
|
|
106
|
-
const environmentId = this.siteConfig.apiEnvironmentId;
|
|
107
|
-
if (!token || !environmentId)
|
|
108
|
-
return;
|
|
109
|
-
try {
|
|
110
|
-
await new ApiClient(token).put('api-routes', { environmentId, filenames });
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
// non-blocking: reporting the route list must never interrupt the push flow
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
1
|
+
import { API_FILES_RESOURCE, resourcePushCommand } from '../../lib/php-files-command.js';
|
|
2
|
+
export default resourcePushCommand(API_FILES_RESOURCE);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
type RemoteApp = {
|
|
3
|
+
buildId: null | string;
|
|
4
|
+
committed: boolean;
|
|
5
|
+
deployedAt: null | string;
|
|
6
|
+
fileCount: number;
|
|
7
|
+
name: string;
|
|
8
|
+
routing: null | string;
|
|
9
|
+
totalBytes: number;
|
|
10
|
+
};
|
|
11
|
+
export default class List extends LoopressCommand {
|
|
12
|
+
static description: string;
|
|
13
|
+
static enableJsonFlag: boolean;
|
|
14
|
+
static examples: string[];
|
|
15
|
+
run(): Promise<RemoteApp[]>;
|
|
16
|
+
}
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
3
|
+
function humanBytes(bytes) {
|
|
4
|
+
if (bytes < 1024)
|
|
5
|
+
return `${bytes} B`;
|
|
6
|
+
if (bytes < 1024 * 1024)
|
|
7
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
8
|
+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
9
|
+
}
|
|
10
|
+
export default class List extends LoopressCommand {
|
|
11
|
+
static description = 'List single-page apps deployed to WordPress';
|
|
12
|
+
static enableJsonFlag = true;
|
|
13
|
+
static examples = ['$ lps app list'];
|
|
14
|
+
async run() {
|
|
15
|
+
const apps = await this.wp.get('loopress/v1/apps');
|
|
16
|
+
this.log(`Apps (${apps.length}):`);
|
|
17
|
+
if (apps.length === 0) {
|
|
18
|
+
this.log(' (none)');
|
|
19
|
+
return apps;
|
|
20
|
+
}
|
|
21
|
+
for (const app of apps) {
|
|
22
|
+
this.log(` ${app.name}`);
|
|
23
|
+
if (app.committed) {
|
|
24
|
+
this.log(` Build: ${app.buildId ?? '(unknown)'}`);
|
|
25
|
+
this.log(` Files: ${pluralize(app.fileCount, 'file')}, ${humanBytes(app.totalBytes)}`);
|
|
26
|
+
if (app.deployedAt)
|
|
27
|
+
this.log(` Deployed: ${app.deployedAt}`);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
this.log(' (uploaded but never committed, run `lps app push` to finish)');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return apps;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
-
type
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
type PulledApp = {
|
|
3
|
+
files: number;
|
|
4
|
+
name: string;
|
|
5
5
|
};
|
|
6
6
|
type PullResult = {
|
|
7
7
|
orphans: string[];
|
|
8
|
-
pulled:
|
|
9
|
-
skipped: number;
|
|
8
|
+
pulled: PulledApp[];
|
|
10
9
|
status: 'dry-run' | 'success';
|
|
11
10
|
};
|
|
12
11
|
export default class Pull extends LoopressCommand {
|
|
@@ -21,5 +20,8 @@ export default class Pull extends LoopressCommand {
|
|
|
21
20
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
22
21
|
};
|
|
23
22
|
run(): Promise<PullResult>;
|
|
23
|
+
private findOrphanApps;
|
|
24
|
+
private pullApp;
|
|
25
|
+
private removeOrphanApps;
|
|
24
26
|
}
|
|
25
27
|
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { Args } from '@oclif/core';
|
|
3
|
+
import { Buffer } from 'node:buffer';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { APP_CONFIG_FILENAME } from '../../lib/app-manifest.js';
|
|
8
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
9
|
+
import { isInteractive } from '../../lib/interactive.js';
|
|
10
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
11
|
+
export default class Pull extends LoopressCommand {
|
|
12
|
+
static args = {
|
|
13
|
+
path: Args.string({ description: 'Path to apps directory (overrides project config)' }),
|
|
14
|
+
};
|
|
15
|
+
static description = 'Pull single-page app bundles from WordPress into local files';
|
|
16
|
+
static enableJsonFlag = true;
|
|
17
|
+
static examples = ['$ lps app pull'];
|
|
18
|
+
static flags = {
|
|
19
|
+
...LoopressCommand.dryRunFlag,
|
|
20
|
+
...LoopressCommand.yesFlag,
|
|
21
|
+
};
|
|
22
|
+
async run() {
|
|
23
|
+
const { args } = await this.parse(Pull);
|
|
24
|
+
const { url } = this.siteConfig;
|
|
25
|
+
const path = this.resolveAppsPath(args.path);
|
|
26
|
+
this.log(`Pulling apps from ${url}`);
|
|
27
|
+
this.log(`Apps path: ${path}`);
|
|
28
|
+
const remoteApps = await this.wp.get('loopress/v1/apps');
|
|
29
|
+
const pullable = remoteApps.filter((app) => app.committed);
|
|
30
|
+
const orphans = await this.findOrphanApps(path, new Set(pullable.map((app) => app.name)));
|
|
31
|
+
if (this.dryRun) {
|
|
32
|
+
this.log(`[dry-run] Would pull ${pluralize(pullable.length, 'app')} to ${path}`);
|
|
33
|
+
if (orphans.length > 0) {
|
|
34
|
+
this.log(`[dry-run] Would remove ${pluralize(orphans.length, 'local app')}: ${orphans.join(', ')}`);
|
|
35
|
+
}
|
|
36
|
+
return { orphans, pulled: pullable.map((app) => ({ files: 0, name: app.name })), status: 'dry-run' };
|
|
37
|
+
}
|
|
38
|
+
const pulled = [];
|
|
39
|
+
for (const app of pullable) {
|
|
40
|
+
pulled.push(await this.pullApp(path, app.name));
|
|
41
|
+
}
|
|
42
|
+
await this.removeOrphanApps(path, orphans);
|
|
43
|
+
this.log(`Pulled ${pluralize(pulled.length, 'app')} to ${path}`);
|
|
44
|
+
return { orphans, pulled, status: 'success' };
|
|
45
|
+
}
|
|
46
|
+
async findOrphanApps(path, keep) {
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = await readdir(path, { withFileTypes: true });
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code === 'ENOENT')
|
|
53
|
+
return [];
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
return entries
|
|
57
|
+
.filter((entry) => entry.isDirectory())
|
|
58
|
+
.map((entry) => entry.name)
|
|
59
|
+
.filter((name) => existsSync(join(path, name, APP_CONFIG_FILENAME)) && !keep.has(name));
|
|
60
|
+
}
|
|
61
|
+
async pullApp(path, name) {
|
|
62
|
+
const manifest = await this.wp.get(`loopress/v1/apps/${name}/manifest`);
|
|
63
|
+
const distDir = join(path, name, 'dist');
|
|
64
|
+
for (const file of manifest.files) {
|
|
65
|
+
const { content } = await this.wp.get(`loopress/v1/apps/${name}/asset?path=${encodeURIComponent(file.path)}`);
|
|
66
|
+
const target = join(distDir, file.path);
|
|
67
|
+
await mkdir(dirname(target), { recursive: true });
|
|
68
|
+
await writeFile(target, Buffer.from(content, 'base64'));
|
|
69
|
+
}
|
|
70
|
+
await writeFile(join(path, name, APP_CONFIG_FILENAME), JSON.stringify({ assetsDir: 'dist', mountSelector: manifest.mountSelector, name: manifest.name, routing: manifest.routing }, null, 2) + '\n');
|
|
71
|
+
this.log(` ${name}: ${pluralize(manifest.files.length, 'file')} (build ${manifest.buildId})`);
|
|
72
|
+
return { files: manifest.files.length, name };
|
|
73
|
+
}
|
|
74
|
+
async removeOrphanApps(path, orphans) {
|
|
75
|
+
if (orphans.length === 0)
|
|
76
|
+
return;
|
|
77
|
+
const description = `${pluralize(orphans.length, 'local app')} no longer on WordPress: ${orphans.join(', ')}`;
|
|
78
|
+
if (!this.yes && isInteractive()) {
|
|
79
|
+
const isProceed = await confirm({ default: true, message: `Remove ${description}?` });
|
|
80
|
+
if (!isProceed) {
|
|
81
|
+
this.log(`Kept ${description}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const name of orphans)
|
|
86
|
+
await rm(join(path, name), { force: true, recursive: true });
|
|
87
|
+
if (this.yes || isInteractive()) {
|
|
88
|
+
this.log(`Removed ${description}`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
this.warn(`Removed ${description}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
type PushedApp = {
|
|
3
|
+
buildId: string;
|
|
4
|
+
name: string;
|
|
5
|
+
uploaded: number;
|
|
6
|
+
};
|
|
7
|
+
type PushResult = {
|
|
8
|
+
pushed: PushedApp[];
|
|
9
|
+
status: 'dry-run' | 'success';
|
|
10
|
+
};
|
|
11
|
+
export default class Push extends PushCommand {
|
|
12
|
+
static args: {
|
|
13
|
+
name: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
14
|
+
};
|
|
15
|
+
static description: string;
|
|
16
|
+
static enableJsonFlag: boolean;
|
|
17
|
+
static examples: string[];
|
|
18
|
+
static flags: {
|
|
19
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
20
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
21
|
+
};
|
|
22
|
+
run(): Promise<PushResult>;
|
|
23
|
+
private appDirNames;
|
|
24
|
+
private pushApp;
|
|
25
|
+
private remoteFiles;
|
|
26
|
+
private uploadAsset;
|
|
27
|
+
}
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { APP_CONFIG_FILENAME, diffFiles, loadAppManifest, } from '../../lib/app-manifest.js';
|
|
6
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
7
|
+
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
8
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
9
|
+
export default class Push extends PushCommand {
|
|
10
|
+
static args = {
|
|
11
|
+
name: Args.string({ description: 'Push only this app (defaults to every app in the directory)' }),
|
|
12
|
+
};
|
|
13
|
+
static description = 'Push built single-page app bundles to WordPress. Each app is an `apps/<name>/` directory holding a loopress.app.json and a built dist/ folder. Only files whose content changed are uploaded.';
|
|
14
|
+
static enableJsonFlag = true;
|
|
15
|
+
static examples = ['$ lps app push', '$ lps app push search'];
|
|
16
|
+
static flags = {
|
|
17
|
+
...PushCommand.dryRunFlag,
|
|
18
|
+
...PushCommand.yesFlag,
|
|
19
|
+
};
|
|
20
|
+
async run() {
|
|
21
|
+
const { args } = await this.parse(Push);
|
|
22
|
+
const { url } = this.siteConfig;
|
|
23
|
+
const path = this.resolveAppsPath();
|
|
24
|
+
this.log(`Pushing apps to ${url}`);
|
|
25
|
+
this.log(`Apps path: ${path}`);
|
|
26
|
+
const dirNames = await this.appDirNames(path, args.name);
|
|
27
|
+
this.log(`Found ${pluralize(dirNames.length, 'app')} to push`);
|
|
28
|
+
const pushed = [];
|
|
29
|
+
await this.runPushTasks(dirNames, (dirName) => dirName, async (dirName, task) => {
|
|
30
|
+
const result = await this.pushApp(join(path, dirName), dirName, task);
|
|
31
|
+
if (result)
|
|
32
|
+
pushed.push(result);
|
|
33
|
+
});
|
|
34
|
+
if (this.failedCount > 0) {
|
|
35
|
+
this.error(`${pluralize(this.failedCount, 'app')} failed to push.`);
|
|
36
|
+
}
|
|
37
|
+
if (this.dryRun)
|
|
38
|
+
return { pushed, status: 'dry-run' };
|
|
39
|
+
await this.recordSuccess();
|
|
40
|
+
this.log('All apps pushed.');
|
|
41
|
+
return { pushed, status: 'success' };
|
|
42
|
+
}
|
|
43
|
+
async appDirNames(path, only) {
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = await readdir(path, { withFileTypes: true });
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT') {
|
|
50
|
+
this.error(`Apps directory not found: ${path}. Create ${join(path, '<name>', APP_CONFIG_FILENAME)} first.`);
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
const names = entries
|
|
55
|
+
.filter((entry) => entry.isDirectory())
|
|
56
|
+
.map((entry) => entry.name)
|
|
57
|
+
.filter((name) => existsSync(join(path, name, APP_CONFIG_FILENAME)))
|
|
58
|
+
.sort((a, b) => a.localeCompare(b));
|
|
59
|
+
if (only) {
|
|
60
|
+
if (!names.includes(only)) {
|
|
61
|
+
this.error(`No app "${only}" in ${path} (looked for ${join(only, APP_CONFIG_FILENAME)}).`);
|
|
62
|
+
}
|
|
63
|
+
return [only];
|
|
64
|
+
}
|
|
65
|
+
if (names.length === 0) {
|
|
66
|
+
this.error(`No apps found in ${path}. Each app needs a ${APP_CONFIG_FILENAME} and a built dist/ folder.`);
|
|
67
|
+
}
|
|
68
|
+
return names;
|
|
69
|
+
}
|
|
70
|
+
async pushApp(appDir, dirName, task) {
|
|
71
|
+
let manifest;
|
|
72
|
+
let distDir;
|
|
73
|
+
try {
|
|
74
|
+
;
|
|
75
|
+
({ distDir, manifest } = await loadAppManifest(appDir, dirName));
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
this.reportTaskFailure(`${dirName}: ${error.message}`, error, task);
|
|
79
|
+
}
|
|
80
|
+
const remote = await this.remoteFiles(manifest.name);
|
|
81
|
+
const changed = diffFiles(manifest.files, remote);
|
|
82
|
+
if (this.dryRun) {
|
|
83
|
+
if (task) {
|
|
84
|
+
task.output =
|
|
85
|
+
changed.length === 0
|
|
86
|
+
? `[dry-run] ${manifest.name}: up to date (build ${manifest.buildId})`
|
|
87
|
+
: `[dry-run] ${manifest.name}: would upload ${pluralize(changed.length, 'file')}, then commit build ${manifest.buildId}`;
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
for (const [index, file] of changed.entries()) {
|
|
92
|
+
if (task)
|
|
93
|
+
task.output = `${manifest.name}: uploading ${file.path} (${index + 1}/${changed.length})`;
|
|
94
|
+
await this.uploadAsset(manifest.name, distDir, file);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await this.wp.post(`loopress/v1/apps/${manifest.name}/commit`, manifest);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
this.reportTaskFailure(`${manifest.name}: commit failed: ${error.message}`, error, task);
|
|
101
|
+
}
|
|
102
|
+
if (task) {
|
|
103
|
+
task.output =
|
|
104
|
+
changed.length === 0
|
|
105
|
+
? `${manifest.name}: already up to date (build ${manifest.buildId})`
|
|
106
|
+
: `${manifest.name}: uploaded ${pluralize(changed.length, 'file')}, committed build ${manifest.buildId}`;
|
|
107
|
+
}
|
|
108
|
+
return { buildId: manifest.buildId, name: manifest.name, uploaded: changed.length };
|
|
109
|
+
}
|
|
110
|
+
async remoteFiles(name) {
|
|
111
|
+
try {
|
|
112
|
+
const remote = await this.wp.get(`loopress/v1/apps/${name}/manifest`);
|
|
113
|
+
return remote.files ?? [];
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (isNotFoundError(error))
|
|
117
|
+
return [];
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async uploadAsset(name, distDir, file) {
|
|
122
|
+
const content = (await readFile(join(distDir, file.path))).toString('base64');
|
|
123
|
+
try {
|
|
124
|
+
await this.wp.put(`loopress/v1/apps/${name}/assets`, { content, encoding: 'base64', path: file.path });
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
this.reportTaskFailure(`${name}: failed to upload ${file.path}: ${error.message}`, error);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
type RemoveResult = {
|
|
3
|
+
deleted: boolean;
|
|
4
|
+
name: string;
|
|
5
|
+
status: 'aborted' | 'dry-run' | 'success';
|
|
6
|
+
};
|
|
7
|
+
export default class Remove extends LoopressCommand {
|
|
8
|
+
static args: {
|
|
9
|
+
name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
10
|
+
};
|
|
11
|
+
static description: string;
|
|
12
|
+
static enableJsonFlag: boolean;
|
|
13
|
+
static examples: string[];
|
|
14
|
+
static flags: {
|
|
15
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
};
|
|
18
|
+
run(): Promise<RemoveResult>;
|
|
19
|
+
}
|
|
20
|
+
export {};
|