@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.
Files changed (117) hide show
  1. package/README.md +794 -85
  2. package/dist/commands/acf/diff.d.ts +2 -0
  3. package/dist/commands/acf/diff.js +5 -0
  4. package/dist/commands/api/diff.d.ts +2 -0
  5. package/dist/commands/api/diff.js +5 -0
  6. package/dist/commands/api/list.d.ts +2 -12
  7. package/dist/commands/api/list.js +2 -20
  8. package/dist/commands/api/pull.d.ts +2 -20
  9. package/dist/commands/api/pull.js +2 -53
  10. package/dist/commands/api/push.d.ts +2 -22
  11. package/dist/commands/api/push.js +2 -116
  12. package/dist/commands/app/list.d.ts +17 -0
  13. package/dist/commands/app/list.js +35 -0
  14. package/dist/commands/{page → app}/pull.d.ts +7 -5
  15. package/dist/commands/app/pull.js +94 -0
  16. package/dist/commands/app/push.d.ts +28 -0
  17. package/dist/commands/app/push.js +130 -0
  18. package/dist/commands/app/remove.d.ts +20 -0
  19. package/dist/commands/app/remove.js +42 -0
  20. package/dist/commands/composer/pull.js +6 -16
  21. package/dist/commands/composer/push.d.ts +1 -0
  22. package/dist/commands/composer/push.js +43 -19
  23. package/dist/commands/dev.js +1 -1
  24. package/dist/commands/diff.d.ts +13 -0
  25. package/dist/commands/diff.js +42 -0
  26. package/dist/commands/form/diff.d.ts +2 -0
  27. package/dist/commands/form/diff.js +5 -0
  28. package/dist/commands/hook/diff.d.ts +2 -0
  29. package/dist/commands/hook/diff.js +5 -0
  30. package/dist/commands/hook/list.d.ts +2 -0
  31. package/dist/commands/hook/list.js +2 -0
  32. package/dist/commands/hook/pull.d.ts +2 -0
  33. package/dist/commands/hook/pull.js +2 -0
  34. package/dist/commands/hook/push.d.ts +2 -0
  35. package/dist/commands/hook/push.js +2 -0
  36. package/dist/commands/init.js +2 -1
  37. package/dist/commands/option/add.d.ts +15 -0
  38. package/dist/commands/option/add.js +47 -0
  39. package/dist/commands/option/diff.d.ts +2 -0
  40. package/dist/commands/option/diff.js +5 -0
  41. package/dist/commands/{page → option}/list.d.ts +5 -1
  42. package/dist/commands/option/list.js +151 -0
  43. package/dist/commands/option/pull.d.ts +16 -0
  44. package/dist/commands/option/pull.js +75 -0
  45. package/dist/commands/{page → option}/push.d.ts +3 -8
  46. package/dist/commands/option/push.js +66 -0
  47. package/dist/commands/option/remove.d.ts +22 -0
  48. package/dist/commands/option/remove.js +69 -0
  49. package/dist/commands/plugin/add.d.ts +1 -0
  50. package/dist/commands/plugin/add.js +18 -8
  51. package/dist/commands/plugin/audit.d.ts +30 -0
  52. package/dist/commands/plugin/audit.js +138 -0
  53. package/dist/commands/plugin/pull.d.ts +1 -2
  54. package/dist/commands/plugin/pull.js +11 -11
  55. package/dist/commands/plugin/push.d.ts +13 -8
  56. package/dist/commands/plugin/push.js +161 -67
  57. package/dist/commands/plugin/status.d.ts +22 -0
  58. package/dist/commands/plugin/status.js +65 -0
  59. package/dist/commands/pull.js +3 -2
  60. package/dist/commands/push.js +4 -3
  61. package/dist/commands/seo/diff.d.ts +2 -0
  62. package/dist/commands/seo/diff.js +5 -0
  63. package/dist/commands/snippet/diff.d.ts +2 -0
  64. package/dist/commands/snippet/diff.js +5 -0
  65. package/dist/commands/theme/add.d.ts +13 -0
  66. package/dist/commands/theme/add.js +35 -0
  67. package/dist/commands/theme/pull.d.ts +21 -0
  68. package/dist/commands/theme/pull.js +37 -0
  69. package/dist/commands/theme/push.d.ts +22 -0
  70. package/dist/commands/theme/push.js +123 -0
  71. package/dist/commands/theme/status.d.ts +20 -0
  72. package/dist/commands/theme/status.js +51 -0
  73. package/dist/lib/app-manifest.d.ts +36 -0
  74. package/dist/lib/app-manifest.js +162 -0
  75. package/dist/lib/base.d.ts +3 -1
  76. package/dist/lib/base.js +14 -19
  77. package/dist/lib/dev-watch.d.ts +1 -1
  78. package/dist/lib/dev-watch.js +1 -2
  79. package/dist/lib/diff-command.d.ts +47 -0
  80. package/dist/lib/diff-command.js +180 -0
  81. package/dist/lib/diff-state.d.ts +22 -0
  82. package/dist/lib/diff-state.js +88 -0
  83. package/dist/lib/find-orphaned-files.js +1 -1
  84. package/dist/lib/interactive.d.ts +1 -0
  85. package/dist/lib/interactive.js +10 -0
  86. package/dist/lib/php-files-command.d.ts +54 -0
  87. package/dist/lib/php-files-command.js +216 -0
  88. package/dist/lib/put-or-create.js +1 -1
  89. package/dist/lib/readdir-tolerant.d.ts +1 -0
  90. package/dist/lib/readdir-tolerant.js +3 -1
  91. package/dist/lib/resource-state.d.ts +14 -0
  92. package/dist/lib/resource-state.js +361 -0
  93. package/dist/lib/validate-local.js +15 -7
  94. package/dist/lib/wp-client.d.ts +5 -0
  95. package/dist/lib/wp-client.js +56 -0
  96. package/dist/types/plugin.d.ts +1 -0
  97. package/dist/types/project-config.generated.d.ts +19 -5
  98. package/dist/utils/option-format.d.ts +31 -0
  99. package/dist/utils/option-format.js +51 -0
  100. package/dist/utils/plugin-sync.d.ts +26 -0
  101. package/dist/utils/plugin-sync.js +31 -0
  102. package/dist/utils/plugins.d.ts +15 -2
  103. package/dist/utils/plugins.js +57 -12
  104. package/dist/utils/resource-dirs.d.ts +13 -0
  105. package/dist/utils/resource-dirs.js +34 -0
  106. package/dist/utils/themes.d.ts +30 -0
  107. package/dist/utils/themes.js +29 -0
  108. package/dist/utils/version.d.ts +2 -0
  109. package/dist/utils/version.js +21 -0
  110. package/oclif.manifest.json +1897 -244
  111. package/package.json +26 -19
  112. package/schemas/project-config.schema.json +32 -9
  113. package/dist/commands/page/list.js +0 -19
  114. package/dist/commands/page/pull.js +0 -57
  115. package/dist/commands/page/push.js +0 -132
  116. package/dist/utils/page-format.d.ts +0 -7
  117. package/dist/utils/page-format.js +0 -78
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/diff-command.js").DiffCommand;
2
+ export default _default;
@@ -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,2 @@
1
+ declare const _default: typeof import("../../lib/diff-command.js").DiffCommand;
2
+ export default _default;
@@ -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
- import { LoopressCommand } from '../../lib/base.js';
2
- type ApiFile = {
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 { LoopressCommand } from '../../lib/base.js';
2
- import { pluralize } from '../../utils/pluralize.js';
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
- import { LoopressCommand } from '../../lib/base.js';
2
- type PullResult = {
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 { Args } from '@oclif/core';
2
- import { mkdir, writeFile } from 'node:fs/promises';
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
- import { PushCommand } from '../../lib/push-command.js';
2
- type PushResult = {
3
- pushed: string[];
4
- status: 'dry-run' | 'success';
5
- };
6
- export default class Push extends PushCommand {
7
- static args: {
8
- 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 { Args } from '@oclif/core';
2
- import { relative, sep } from 'node:path';
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 PulledPage = {
3
- id: number;
4
- title: string;
2
+ type PulledApp = {
3
+ files: number;
4
+ name: string;
5
5
  };
6
6
  type PullResult = {
7
7
  orphans: string[];
8
- pulled: PulledPage[];
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 {};