@loopress/cli 0.21.0 → 0.23.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 (103) hide show
  1. package/README.md +457 -67
  2. package/dist/commands/acf/list.d.ts +2 -2
  3. package/dist/commands/acf/list.js +2 -5
  4. package/dist/commands/acf/pull.js +9 -22
  5. package/dist/commands/acf/push.js +3 -6
  6. package/dist/commands/api/list.js +2 -1
  7. package/dist/commands/api/publish.js +6 -22
  8. package/dist/commands/api/pull.js +12 -17
  9. package/dist/commands/api/push.js +7 -10
  10. package/dist/commands/app/list.d.ts +17 -0
  11. package/dist/commands/app/list.js +35 -0
  12. package/dist/commands/app/pull.d.ts +27 -0
  13. package/dist/commands/app/pull.js +94 -0
  14. package/dist/commands/app/push.d.ts +28 -0
  15. package/dist/commands/app/push.js +130 -0
  16. package/dist/commands/app/remove.d.ts +20 -0
  17. package/dist/commands/app/remove.js +42 -0
  18. package/dist/commands/composer/push.d.ts +1 -0
  19. package/dist/commands/composer/push.js +43 -19
  20. package/dist/commands/dev.js +1 -1
  21. package/dist/commands/doctor.js +2 -1
  22. package/dist/commands/form/list.d.ts +2 -4
  23. package/dist/commands/form/list.js +3 -10
  24. package/dist/commands/form/pull.js +15 -24
  25. package/dist/commands/form/push.js +17 -42
  26. package/dist/commands/init.js +1 -0
  27. package/dist/commands/login.js +6 -2
  28. package/dist/commands/logout.js +2 -1
  29. package/dist/commands/page/pull.js +17 -28
  30. package/dist/commands/page/push.js +27 -52
  31. package/dist/commands/plugin/add.d.ts +1 -0
  32. package/dist/commands/plugin/add.js +18 -8
  33. package/dist/commands/plugin/audit.d.ts +30 -0
  34. package/dist/commands/plugin/audit.js +138 -0
  35. package/dist/commands/plugin/pull.d.ts +1 -1
  36. package/dist/commands/plugin/pull.js +13 -15
  37. package/dist/commands/plugin/push.d.ts +14 -5
  38. package/dist/commands/plugin/push.js +171 -60
  39. package/dist/commands/plugin/status.d.ts +22 -0
  40. package/dist/commands/plugin/status.js +65 -0
  41. package/dist/commands/project/config.js +4 -1
  42. package/dist/commands/project/list.d.ts +16 -1
  43. package/dist/commands/project/list.js +22 -16
  44. package/dist/commands/project/pull.js +3 -2
  45. package/dist/commands/project/push.d.ts +5 -0
  46. package/dist/commands/project/push.js +79 -58
  47. package/dist/commands/promote.d.ts +18 -0
  48. package/dist/commands/promote.js +90 -0
  49. package/dist/commands/pull.d.ts +12 -0
  50. package/dist/commands/pull.js +51 -0
  51. package/dist/commands/push.d.ts +13 -0
  52. package/dist/commands/push.js +57 -0
  53. package/dist/commands/seo/list.d.ts +9 -2
  54. package/dist/commands/seo/list.js +9 -11
  55. package/dist/commands/seo/pull.js +14 -37
  56. package/dist/commands/seo/push.js +24 -48
  57. package/dist/commands/snippet/list.js +2 -1
  58. package/dist/commands/snippet/publish.js +5 -21
  59. package/dist/commands/snippet/pull.js +15 -20
  60. package/dist/commands/snippet/push.js +21 -30
  61. package/dist/commands/theme/add.d.ts +13 -0
  62. package/dist/commands/theme/add.js +35 -0
  63. package/dist/commands/theme/pull.d.ts +21 -0
  64. package/dist/commands/theme/pull.js +37 -0
  65. package/dist/commands/theme/push.d.ts +23 -0
  66. package/dist/commands/theme/push.js +130 -0
  67. package/dist/commands/theme/status.d.ts +20 -0
  68. package/dist/commands/theme/status.js +51 -0
  69. package/dist/commands/validate.d.ts +8 -0
  70. package/dist/commands/validate.js +24 -0
  71. package/dist/lib/app-manifest.d.ts +36 -0
  72. package/dist/lib/app-manifest.js +162 -0
  73. package/dist/lib/base.d.ts +10 -0
  74. package/dist/lib/base.js +56 -22
  75. package/dist/lib/guard-production-push.d.ts +12 -0
  76. package/dist/lib/guard-production-push.js +20 -0
  77. package/dist/lib/load-snippets.js +65 -60
  78. package/dist/lib/local-callback-server.js +9 -4
  79. package/dist/lib/push-command.d.ts +3 -0
  80. package/dist/lib/push-command.js +22 -15
  81. package/dist/lib/put-or-create.d.ts +13 -0
  82. package/dist/lib/put-or-create.js +19 -0
  83. package/dist/lib/readdir-tolerant.d.ts +5 -0
  84. package/dist/lib/readdir-tolerant.js +11 -0
  85. package/dist/lib/resolve-linked-project.d.ts +8 -0
  86. package/dist/lib/resolve-linked-project.js +27 -0
  87. package/dist/lib/validate-local.d.ts +10 -0
  88. package/dist/lib/validate-local.js +183 -0
  89. package/dist/types/plugin.d.ts +1 -0
  90. package/dist/types/project-config.generated.d.ts +11 -1
  91. package/dist/utils/plugin-sync.d.ts +26 -0
  92. package/dist/utils/plugin-sync.js +31 -0
  93. package/dist/utils/plugins.d.ts +15 -2
  94. package/dist/utils/plugins.js +57 -12
  95. package/dist/utils/pluralize.d.ts +1 -0
  96. package/dist/utils/pluralize.js +5 -0
  97. package/dist/utils/themes.d.ts +30 -0
  98. package/dist/utils/themes.js +29 -0
  99. package/dist/utils/version.d.ts +2 -0
  100. package/dist/utils/version.js +21 -0
  101. package/oclif.manifest.json +986 -125
  102. package/package.json +25 -20
  103. package/schemas/project-config.schema.json +22 -4
@@ -1,10 +1,10 @@
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
  static flags: {
6
- json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
7
  type: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
8
  };
9
- run(): Promise<void>;
9
+ run(): Promise<Record<string, Array<Record<string, unknown>>>>;
10
10
  }
@@ -3,9 +3,9 @@ import { LoopressCommand } from '../../lib/base.js';
3
3
  import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../../utils/acf-format.js';
4
4
  export default class List extends LoopressCommand {
5
5
  static description = 'List ACF field groups, post types, taxonomies, and options pages from WordPress';
6
+ static enableJsonFlag = true;
6
7
  static examples = ['$ lps acf list', '$ lps acf list --type field-groups'];
7
8
  static flags = {
8
- json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
9
9
  type: Flags.string({ description: 'Limit to specific ACF object types', multiple: true, options: ACF_OBJECT_TYPES }),
10
10
  };
11
11
  async run() {
@@ -15,10 +15,6 @@ export default class List extends LoopressCommand {
15
15
  for (const type of types) {
16
16
  byType[type] = await this.wp.get(acfEndpoint(type));
17
17
  }
18
- if (flags.json) {
19
- this.log(JSON.stringify(byType, null, 2));
20
- return;
21
- }
22
18
  for (const type of types) {
23
19
  const objects = byType[type];
24
20
  this.log(`${type} (${objects.length}):`);
@@ -33,5 +29,6 @@ export default class List extends LoopressCommand {
33
29
  }
34
30
  this.log('');
35
31
  }
32
+ return byType;
36
33
  }
37
34
  }
@@ -1,6 +1,5 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
- import { Listr } from 'listr2';
3
- import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { writeFile } from 'node:fs/promises';
4
3
  import { join } from 'node:path';
5
4
  import { LoopressCommand } from '../../lib/base.js';
6
5
  import { basenameKey, findOrphanedFiles } from '../../lib/find-orphaned-files.js';
@@ -39,27 +38,15 @@ export default class Pull extends LoopressCommand {
39
38
  extensions: ['.json'],
40
39
  key: basenameKey,
41
40
  });
42
- if (this.dryRun) {
43
- this.log(`[dry-run] Would pull ${withKey.length} ${type} to ${dir}`);
44
- if (orphans.length > 0) {
45
- this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${dir} no longer present on WordPress: ${orphans.join(', ')}`);
46
- }
41
+ await this.pullDirectory(dir, withKey, orphans, {
42
+ dryRunMessage: `Would pull ${withKey.length} ${type} to ${dir}`,
43
+ orphanReason: `in ${dir} no longer present on WordPress`,
44
+ pulledMessage: `Pulled ${withKey.length} ${type} to ${dir}`,
45
+ title: (object) => getAcfKey(object),
46
+ write: async (object, writeDir) => writeFile(join(writeDir, `${getAcfKey(object)}.json`), JSON.stringify(object, null, 2) + '\n'),
47
+ });
48
+ if (this.dryRun)
47
49
  return;
48
- }
49
- if (withKey.length > 0)
50
- await mkdir(dir, { recursive: true });
51
- await new Listr(withKey.map((object) => {
52
- const key = getAcfKey(object);
53
- return {
54
- async task(_ctx, task) {
55
- await writeFile(join(dir, `${key}.json`), JSON.stringify(object, null, 2) + '\n');
56
- task.output = `Pulled: ${key}`;
57
- },
58
- title: `Pull ${key}`,
59
- };
60
- })).run();
61
- await this.removeOrphanedFiles(dir, orphans, `in ${dir} no longer present on WordPress`);
62
- this.log(`Pulled ${withKey.length} ${type} to ${dir}`);
63
50
  if (skipped > 0) {
64
51
  this.warn(`${skipped} ${type} skipped because they have no key`);
65
52
  }
@@ -1,9 +1,9 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
- import { Listr } from 'listr2';
3
2
  import { join } from 'node:path';
4
3
  import { loadFiles } from '../../lib/load-files.js';
5
4
  import { PushCommand } from '../../lib/push-command.js';
6
5
  import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../../utils/acf-format.js';
6
+ import { pluralize } from '../../utils/pluralize.js';
7
7
  export default class Push extends PushCommand {
8
8
  static args = {
9
9
  path: Args.string({ description: 'Path to ACF directory (overrides project config)' }),
@@ -26,7 +26,7 @@ export default class Push extends PushCommand {
26
26
  await this.pushType(type, path);
27
27
  }
28
28
  if (this.failedCount > 0) {
29
- this.error(`${this.failedCount} ACF object${this.failedCount === 1 ? '' : 's'} failed to push.`);
29
+ this.error(`${pluralize(this.failedCount, 'ACF object')} failed to push.`);
30
30
  }
31
31
  if (this.dryRun)
32
32
  return;
@@ -71,9 +71,6 @@ export default class Push extends PushCommand {
71
71
  if (objects.length === 0)
72
72
  return;
73
73
  this.log(`Found ${objects.length} ${type} to push`);
74
- await new Listr(objects.map((object) => ({
75
- task: async (_ctx, task) => this.pushObject(type, object, task),
76
- title: `Push ${getAcfKey(object) ?? '(unknown)'}`,
77
- })), { concurrent: false, exitOnError: false }).run();
74
+ await this.runPushTasks(objects, (object) => getAcfKey(object) ?? '(unknown)', async (object, task) => this.pushObject(type, object, task));
78
75
  }
79
76
  }
@@ -1,4 +1,5 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ import { pluralize } from '../../utils/pluralize.js';
2
3
  export default class List extends LoopressCommand {
3
4
  static description = 'List custom API route files from WordPress';
4
5
  static enableJsonFlag = true;
@@ -9,7 +10,7 @@ export default class List extends LoopressCommand {
9
10
  this.log('No API route files found');
10
11
  return files;
11
12
  }
12
- this.log(`Found ${files.length} route file${files.length === 1 ? '' : 's'}:`);
13
+ this.log(`Found ${pluralize(files.length, 'route file')}:`);
13
14
  this.log('');
14
15
  for (const file of files) {
15
16
  this.log(` ${file.filename}`);
@@ -1,10 +1,9 @@
1
1
  import { Args, Command } from '@oclif/core';
2
2
  import { basename, join } from 'node:path';
3
- import { authManager } from '../../config/auth.manager.js';
4
- import { configManager } from '../../config/project-config.manager.js';
5
3
  import { ApiClient } from '../../lib/api-client.js';
6
4
  import { loadFiles } from '../../lib/load-files.js';
7
- import { readLocalConfig } from '../../utils/loopress-config.js';
5
+ import { resolveLinkedProject } from '../../lib/resolve-linked-project.js';
6
+ import { pluralize } from '../../utils/pluralize.js';
8
7
  // Publishes to the Loopress api (not a WordPress site), so this does not extend
9
8
  // `LoopressCommand`/`PushCommand`: those force an environment to be resolved, but publishing
10
9
  // for cross-project sharing doesn't need one, same reasoning as `snippet publish`. An API route
@@ -20,22 +19,7 @@ export default class Publish extends Command {
20
19
  static examples = ['$ lps api publish', '$ lps api publish --path ./api'];
21
20
  async run() {
22
21
  const { args } = await this.parse(Publish);
23
- const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token;
24
- if (!token) {
25
- this.error('Not logged in. Run `lps login` first.');
26
- }
27
- const localConfig = await readLocalConfig();
28
- const projectId = localConfig.projectId ?? configManager.getCurrentProject()?.id;
29
- if (!projectId) {
30
- this.error('No project configured. Run `lps project config` first.');
31
- }
32
- const project = configManager.getProject(projectId);
33
- if (!project) {
34
- this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
35
- }
36
- if (!project.apiProjectId) {
37
- this.error(`Project "${project.name}" is not linked to your Loopress account yet. Run \`lps project push\` first.`);
38
- }
22
+ const { apiProjectId, localConfig, token } = await resolveLinkedProject((message) => this.error(message));
39
23
  const path = args.path ?? join(localConfig.rootDir ?? '.', localConfig.apiDir ?? 'api');
40
24
  this.log(`Publishing API routes from ${path}`);
41
25
  const files = await loadFiles(path, {
@@ -45,16 +29,16 @@ export default class Publish extends Command {
45
29
  });
46
30
  const api = new ApiClient(token);
47
31
  try {
48
- await api.post(`projects/${project.apiProjectId}/api-routes/publish/upsert`, {
32
+ await api.post(`projects/${apiProjectId}/api-routes/publish/upsert`, {
49
33
  routes: files,
50
34
  });
51
- await api.post(`projects/${project.apiProjectId}/api-routes/publish/prune`, {
35
+ await api.post(`projects/${apiProjectId}/api-routes/publish/prune`, {
52
36
  filenames: files.map((file) => file.filename),
53
37
  });
54
38
  }
55
39
  catch (error) {
56
40
  this.error(error.message);
57
41
  }
58
- this.log(`Published ${files.length} route${files.length === 1 ? '' : 's'} to your Loopress account.`);
42
+ this.log(`Published ${pluralize(files.length, 'route')} to your Loopress account.`);
59
43
  }
60
44
  }
@@ -1,9 +1,9 @@
1
1
  import { Args } from '@oclif/core';
2
- import { Listr } from 'listr2';
3
2
  import { mkdir, writeFile } from 'node:fs/promises';
4
3
  import { dirname, join } from 'node:path';
5
4
  import { LoopressCommand } from '../../lib/base.js';
6
5
  import { basenameKey, findOrphanedFiles } from '../../lib/find-orphaned-files.js';
6
+ import { pluralize } from '../../utils/pluralize.js';
7
7
  export default class Pull extends LoopressCommand {
8
8
  static args = {
9
9
  path: Args.string({ description: 'Path to api directory (overrides project config)' }),
@@ -31,28 +31,23 @@ export default class Pull extends LoopressCommand {
31
31
  recursive: true,
32
32
  });
33
33
  const pulled = files.map((file) => file.filename);
34
- if (this.dryRun) {
35
- this.log(`[dry-run] Would pull ${files.length} route file${files.length === 1 ? '' : 's'} to ${path}`);
36
- if (orphans.length > 0) {
37
- this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} whose route no longer exists on WordPress: ${orphans.join(', ')}`);
38
- }
39
- return { orphans, pulled, status: 'dry-run' };
40
- }
41
- await mkdir(path, { recursive: true });
42
- await new Listr(files.map((file) => ({
43
- async task(_ctx, task) {
44
- const filePath = join(path, `${file.filename}.php`);
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`);
45
42
  // filename can contain '/' (a path-param route, e.g. invoice-pdf/[order_id]):
46
43
  // writeFile() doesn't create parent directories on its own the way mkdir()'s
47
44
  // recursive option does above for the top-level path.
48
45
  await mkdir(dirname(filePath), { recursive: true });
49
46
  await writeFile(filePath, file.content);
50
- task.output = `Pulled: ${file.filename}`;
51
47
  },
52
- title: `Pull ${file.filename}`,
53
- })), { renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
54
- await this.removeOrphanedFiles(path, orphans, 'whose route no longer exists on WordPress');
55
- this.log(`Pulled ${files.length} route file${files.length === 1 ? '' : 's'} to ${path}`);
48
+ });
49
+ if (this.dryRun)
50
+ return { orphans, pulled, status: 'dry-run' };
56
51
  return { orphans, pulled, status: 'success' };
57
52
  }
58
53
  }
@@ -1,10 +1,10 @@
1
1
  import { Args } from '@oclif/core';
2
- import { Listr } from 'listr2';
3
2
  import { relative, sep } from 'node:path';
4
3
  import { authManager } from '../../config/auth.manager.js';
5
4
  import { ApiClient } from '../../lib/api-client.js';
6
5
  import { loadFiles as loadDirectoryFiles } from '../../lib/load-files.js';
7
6
  import { PushCommand } from '../../lib/push-command.js';
7
+ import { pluralize } from '../../utils/pluralize.js';
8
8
  // Mirrors the server's own allowlist (wordpress-plugin ApiFilesController::isValidFilename()):
9
9
  // filename is sent as a body param (not a URL path segment: a nested path can contain '/' and
10
10
  // '[]', and depending on a server to handle a percent-encoded slash in a URL path correctly is
@@ -36,17 +36,14 @@ export default class Push extends PushCommand {
36
36
  this.log(`Pushing API routes to ${url}`);
37
37
  this.log(`API path: ${path}`);
38
38
  const files = await this.loadFiles(path);
39
- this.log(`Found ${files.length} route file${files.length === 1 ? '' : 's'} to push`);
39
+ this.log(`Found ${pluralize(files.length, 'route file')} to push`);
40
40
  const pushed = [];
41
- await new Listr(files.map((file) => ({
42
- task: async (_ctx, task) => {
43
- await this.pushFile(file, task);
44
- pushed.push(file.filename);
45
- },
46
- title: `Push ${file.filename}`,
47
- })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
41
+ await this.runPushTasks(files, (file) => file.filename, async (file, task) => {
42
+ await this.pushFile(file, task);
43
+ pushed.push(file.filename);
44
+ });
48
45
  if (this.failedCount > 0) {
49
- this.error(`${this.failedCount} route file${this.failedCount === 1 ? '' : 's'} failed to push.`);
46
+ this.error(`${pluralize(this.failedCount, 'route file')} failed to push.`);
50
47
  }
51
48
  if (this.dryRun)
52
49
  return { pushed, status: 'dry-run' };
@@ -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
+ }
@@ -0,0 +1,27 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ type PulledApp = {
3
+ files: number;
4
+ name: string;
5
+ };
6
+ type PullResult = {
7
+ orphans: string[];
8
+ pulled: PulledApp[];
9
+ status: 'dry-run' | 'success';
10
+ };
11
+ export default class Pull extends LoopressCommand {
12
+ static args: {
13
+ path: 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<PullResult>;
23
+ private findOrphanApps;
24
+ private pullApp;
25
+ private removeOrphanApps;
26
+ }
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 {};