@loopress/cli 0.20.1 → 0.22.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 (118) hide show
  1. package/README.md +254 -75
  2. package/dist/commands/acf/list.d.ts +2 -2
  3. package/dist/commands/acf/list.js +4 -6
  4. package/dist/commands/acf/pull.js +9 -22
  5. package/dist/commands/acf/push.js +4 -7
  6. package/dist/commands/api/list.d.ts +7 -4
  7. package/dist/commands/api/list.js +5 -11
  8. package/dist/commands/api/publish.js +7 -23
  9. package/dist/commands/api/pull.d.ts +8 -1
  10. package/dist/commands/api/pull.js +22 -18
  11. package/dist/commands/api/push.d.ts +7 -1
  12. package/dist/commands/api/push.js +50 -23
  13. package/dist/commands/composer/init.js +6 -6
  14. package/dist/commands/composer/pull.d.ts +7 -1
  15. package/dist/commands/composer/pull.js +4 -2
  16. package/dist/commands/composer/push.d.ts +8 -1
  17. package/dist/commands/composer/push.js +3 -1
  18. package/dist/commands/dev.d.ts +13 -0
  19. package/dist/commands/dev.js +97 -0
  20. package/dist/commands/doctor.js +6 -5
  21. package/dist/commands/form/list.d.ts +2 -4
  22. package/dist/commands/form/list.js +3 -10
  23. package/dist/commands/form/pull.js +15 -24
  24. package/dist/commands/form/push.js +17 -42
  25. package/dist/commands/init.js +7 -7
  26. package/dist/commands/login.js +9 -5
  27. package/dist/commands/logout.js +2 -1
  28. package/dist/commands/page/list.d.ts +2 -4
  29. package/dist/commands/page/list.js +3 -10
  30. package/dist/commands/page/pull.d.ts +13 -1
  31. package/dist/commands/page/pull.js +21 -29
  32. package/dist/commands/page/push.d.ts +11 -1
  33. package/dist/commands/page/push.js +31 -49
  34. package/dist/commands/plugin/add.js +3 -3
  35. package/dist/commands/plugin/pull.d.ts +8 -1
  36. package/dist/commands/plugin/pull.js +11 -11
  37. package/dist/commands/plugin/push.d.ts +12 -1
  38. package/dist/commands/plugin/push.js +41 -27
  39. package/dist/commands/project/config.js +12 -9
  40. package/dist/commands/project/list.d.ts +16 -1
  41. package/dist/commands/project/list.js +22 -16
  42. package/dist/commands/project/pull.js +3 -2
  43. package/dist/commands/project/push.d.ts +5 -0
  44. package/dist/commands/project/push.js +84 -63
  45. package/dist/commands/project/switch.js +2 -2
  46. package/dist/commands/promote.d.ts +18 -0
  47. package/dist/commands/promote.js +90 -0
  48. package/dist/commands/pull.d.ts +12 -0
  49. package/dist/commands/pull.js +51 -0
  50. package/dist/commands/push.d.ts +13 -0
  51. package/dist/commands/push.js +57 -0
  52. package/dist/commands/seo/list.d.ts +9 -2
  53. package/dist/commands/seo/list.js +9 -11
  54. package/dist/commands/seo/pull.js +14 -37
  55. package/dist/commands/seo/push.js +24 -48
  56. package/dist/commands/snippet/list.d.ts +3 -4
  57. package/dist/commands/snippet/list.js +5 -11
  58. package/dist/commands/snippet/publish.js +5 -21
  59. package/dist/commands/snippet/pull.d.ts +13 -1
  60. package/dist/commands/snippet/pull.js +18 -20
  61. package/dist/commands/snippet/push.d.ts +11 -1
  62. package/dist/commands/snippet/push.js +29 -28
  63. package/dist/commands/status.d.ts +13 -1
  64. package/dist/commands/status.js +25 -18
  65. package/dist/commands/validate.d.ts +8 -0
  66. package/dist/commands/validate.js +24 -0
  67. package/dist/config/auth.manager.d.ts +2 -2
  68. package/dist/config/project-config.manager.d.ts +1 -1
  69. package/dist/config/project-config.manager.js +5 -6
  70. package/dist/lib/base.d.ts +11 -2
  71. package/dist/lib/base.js +54 -24
  72. package/dist/lib/bootstrap-full-install.d.ts +1 -1
  73. package/dist/lib/dev-watch.d.ts +16 -0
  74. package/dist/lib/dev-watch.js +71 -0
  75. package/dist/lib/find-orphaned-files.d.ts +3 -2
  76. package/dist/lib/find-orphaned-files.js +40 -4
  77. package/dist/lib/github-release.js +1 -1
  78. package/dist/lib/guard-production-push.d.ts +12 -0
  79. package/dist/lib/guard-production-push.js +20 -0
  80. package/dist/lib/interactive.js +1 -1
  81. package/dist/lib/load-files.d.ts +1 -0
  82. package/dist/lib/load-files.js +40 -11
  83. package/dist/lib/load-snippets.d.ts +1 -1
  84. package/dist/lib/load-snippets.js +66 -59
  85. package/dist/lib/local-callback-server.js +15 -8
  86. package/dist/lib/plugin-detection.d.ts +1 -1
  87. package/dist/lib/push-command.d.ts +3 -0
  88. package/dist/lib/push-command.js +22 -15
  89. package/dist/lib/put-or-create.d.ts +13 -0
  90. package/dist/lib/put-or-create.js +19 -0
  91. package/dist/lib/readdir-tolerant.d.ts +5 -0
  92. package/dist/lib/readdir-tolerant.js +11 -0
  93. package/dist/lib/resolve-linked-project.d.ts +8 -0
  94. package/dist/lib/resolve-linked-project.js +27 -0
  95. package/dist/lib/rotate-app-password.d.ts +1 -1
  96. package/dist/lib/rotate-app-password.js +1 -1
  97. package/dist/lib/temp-admin.d.ts +3 -3
  98. package/dist/lib/temp-admin.js +2 -2
  99. package/dist/lib/validate-local.d.ts +10 -0
  100. package/dist/lib/validate-local.js +183 -0
  101. package/dist/lib/wp-authorize-flow.js +5 -1
  102. package/dist/lib/wp-client.d.ts +2 -2
  103. package/dist/lib/wp-client.js +7 -0
  104. package/dist/types/plugin.d.ts +4 -4
  105. package/dist/types/snippet.d.ts +3 -3
  106. package/dist/utils/composer.d.ts +2 -2
  107. package/dist/utils/form-format.js +1 -1
  108. package/dist/utils/loopress-config.d.ts +1 -1
  109. package/dist/utils/page-format.js +2 -2
  110. package/dist/utils/plugins.d.ts +5 -5
  111. package/dist/utils/plugins.js +3 -3
  112. package/dist/utils/pluralize.d.ts +1 -0
  113. package/dist/utils/pluralize.js +5 -0
  114. package/dist/utils/seo-format.d.ts +6 -6
  115. package/dist/utils/snippet-format.d.ts +2 -2
  116. package/dist/utils/snippet-format.js +10 -5
  117. package/oclif.manifest.json +394 -57
  118. package/package.json +18 -16
@@ -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}):`);
@@ -28,9 +24,11 @@ export default class List extends LoopressCommand {
28
24
  continue;
29
25
  }
30
26
  for (const object of objects) {
31
- this.log(` ${getAcfKey(object) ?? '(no key)'}. ${String(object.title ?? '(untitled)')}`);
27
+ const title = typeof object.title === 'string' ? object.title : '(untitled)';
28
+ this.log(` ${getAcfKey(object) ?? '(no key)'}. ${title}`);
32
29
  }
33
30
  this.log('');
34
31
  }
32
+ return byType;
35
33
  }
36
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;
@@ -36,7 +36,7 @@ export default class Push extends PushCommand {
36
36
  async loadObjects(dir) {
37
37
  return loadFiles(dir, {
38
38
  extension: '.json',
39
- onSkip: (message) => this.warn(message),
39
+ onSkip: (message) => { this.warn(message); },
40
40
  parse(raw) {
41
41
  const parsed = JSON.parse(raw);
42
42
  if (typeof parsed !== 'object' || parsed === null || getAcfKey(parsed) === null) {
@@ -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,9 +1,12 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ type ApiFile = {
3
+ content: string;
4
+ filename: string;
5
+ };
2
6
  export default class List extends LoopressCommand {
3
7
  static description: string;
8
+ static enableJsonFlag: boolean;
4
9
  static examples: string[];
5
- static flags: {
6
- json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
- };
8
- run(): Promise<void>;
10
+ run(): Promise<ApiFile[]>;
9
11
  }
12
+ export {};
@@ -1,26 +1,20 @@
1
- import { Flags } from '@oclif/core';
2
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ import { pluralize } from '../../utils/pluralize.js';
3
3
  export default class List extends LoopressCommand {
4
4
  static description = 'List custom API route files from WordPress';
5
+ static enableJsonFlag = true;
5
6
  static examples = ['$ lps api list'];
6
- static flags = {
7
- json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
8
- };
9
7
  async run() {
10
- const { flags } = await this.parse(List);
11
8
  const files = await this.wp.get('loopress/v1/api-files');
12
- if (flags.json) {
13
- this.log(JSON.stringify(files, null, 2));
14
- return;
15
- }
16
9
  if (files.length === 0) {
17
10
  this.log('No API route files found');
18
- return;
11
+ return files;
19
12
  }
20
- this.log(`Found ${files.length} route file${files.length === 1 ? '' : 's'}:`);
13
+ this.log(`Found ${pluralize(files.length, 'route file')}:`);
21
14
  this.log('');
22
15
  for (const file of files) {
23
16
  this.log(` ${file.filename}`);
24
17
  }
18
+ return files;
25
19
  }
26
20
  }
@@ -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,41 +19,26 @@ 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, {
42
26
  extension: '.php',
43
- onSkip: (message) => this.warn(message),
27
+ onSkip: (message) => { this.warn(message); },
44
28
  parse: (raw, filePath) => ({ code: raw, filename: basename(filePath, '.php') }),
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,13 +1,20 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ type PullResult = {
3
+ orphans: string[];
4
+ pulled: string[];
5
+ status: 'dry-run' | 'success';
6
+ };
2
7
  export default class Pull extends LoopressCommand {
3
8
  static args: {
4
9
  path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
10
  };
6
11
  static description: string;
12
+ static enableJsonFlag: boolean;
7
13
  static examples: string[];
8
14
  static flags: {
9
15
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
17
  };
12
- run(): Promise<void>;
18
+ run(): Promise<PullResult>;
13
19
  }
20
+ export {};
@@ -1,14 +1,15 @@
1
1
  import { Args } from '@oclif/core';
2
- import { Listr } from 'listr2';
3
2
  import { mkdir, writeFile } from 'node:fs/promises';
4
- import { join } from 'node:path';
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)' }),
10
10
  };
11
11
  static description = 'Pull custom API route files from WordPress';
12
+ static enableJsonFlag = true;
12
13
  static examples = ['$ lps api pull', '$ lps api pull --path ./api'];
13
14
  static flags = {
14
15
  ...LoopressCommand.dryRunFlag,
@@ -27,23 +28,26 @@ export default class Pull extends LoopressCommand {
27
28
  const orphans = await findOrphanedFiles(path, new Set(files.map((file) => file.filename)), {
28
29
  extensions: ['.php'],
29
30
  key: basenameKey,
31
+ recursive: true,
30
32
  });
31
- if (this.dryRun) {
32
- this.log(`[dry-run] Would pull ${files.length} route file${files.length === 1 ? '' : 's'} to ${path}`);
33
- if (orphans.length > 0) {
34
- this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} whose route no longer exists on WordPress: ${orphans.join(', ')}`);
35
- }
36
- return;
37
- }
38
- await mkdir(path, { recursive: true });
39
- await new Listr(files.map((file) => ({
40
- async task(_ctx, task) {
41
- await writeFile(join(path, `${file.filename}.php`), file.content);
42
- task.output = `Pulled: ${file.filename}`;
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);
43
47
  },
44
- title: `Pull ${file.filename}`,
45
- }))).run();
46
- await this.removeOrphanedFiles(path, orphans, 'whose route no longer exists on WordPress');
47
- this.log(`Pulled ${files.length} route file${files.length === 1 ? '' : 's'} to ${path}`);
48
+ });
49
+ if (this.dryRun)
50
+ return { orphans, pulled, status: 'dry-run' };
51
+ return { orphans, pulled, status: 'success' };
48
52
  }
49
53
  }
@@ -1,16 +1,22 @@
1
1
  import { PushCommand } from '../../lib/push-command.js';
2
+ type PushResult = {
3
+ pushed: string[];
4
+ status: 'dry-run' | 'success';
5
+ };
2
6
  export default class Push extends PushCommand {
3
7
  static args: {
4
8
  path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
9
  };
6
10
  static description: string;
11
+ static enableJsonFlag: boolean;
7
12
  static examples: string[];
8
13
  static flags: {
9
14
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
15
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
16
  };
12
- run(): Promise<void>;
17
+ run(): Promise<PushResult>;
13
18
  private loadFiles;
14
19
  private pushFile;
15
20
  private syncApiRoutes;
16
21
  }
22
+ export {};
@@ -1,23 +1,29 @@
1
1
  import { Args } from '@oclif/core';
2
- import { Listr } from 'listr2';
3
- import { basename } from 'node:path';
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';
8
- // Mirrors the server's own allowlist (wordpress-plugin ApiFilesController::FILENAME_PATTERN):
9
- // the filename becomes a URL path segment matched against this exact regex by the WP REST
10
- // route itself. A filename that doesn't match never reaches the controller, WordPress's
11
- // router returns a generic 404 before validate_callback runs, which the CLI's shared error
12
- // formatter reports as "is the plugin installed?", a confusing message for what is actually
13
- // an invalid filename. Checking client-side first turns that into an accurate error and
14
- // skips a network round-trip that could only ever fail.
15
- const FILENAME_PATTERN = /^[a-z0-9-]+$/;
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;
16
21
  export default class Push extends PushCommand {
17
22
  static args = {
18
23
  path: Args.string({ description: 'Path to api directory (overrides project config)' }),
19
24
  };
20
25
  static description = 'Push custom API route files to WordPress';
26
+ static enableJsonFlag = true;
21
27
  static examples = ['$ lps api push', '$ lps api push --path ./api'];
22
28
  static flags = {
23
29
  ...PushCommand.dryRunFlag,
@@ -30,30 +36,44 @@ export default class Push extends PushCommand {
30
36
  this.log(`Pushing API routes to ${url}`);
31
37
  this.log(`API path: ${path}`);
32
38
  const files = await this.loadFiles(path);
33
- this.log(`Found ${files.length} route file${files.length === 1 ? '' : 's'} to push`);
34
- await new Listr(files.map((file) => ({
35
- task: async (_ctx, task) => this.pushFile(file, task),
36
- title: `Push ${file.filename}`,
37
- })), { concurrent: false, exitOnError: false }).run();
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
+ });
38
45
  if (this.failedCount > 0) {
39
- this.error(`${this.failedCount} route file${this.failedCount === 1 ? '' : 's'} failed to push.`);
46
+ this.error(`${pluralize(this.failedCount, 'route file')} failed to push.`);
40
47
  }
41
48
  if (this.dryRun)
42
- return;
49
+ return { pushed, status: 'dry-run' };
43
50
  await this.recordSuccess();
44
51
  await this.syncApiRoutes(files.map((file) => file.filename));
45
52
  this.log('All API routes pushed.');
53
+ return { pushed, status: 'success' };
46
54
  }
47
55
  async loadFiles(path) {
48
56
  return loadDirectoryFiles(path, {
49
57
  extension: '.php',
50
- onSkip: (message) => this.warn(message),
51
- parse: (raw, filePath) => ({ content: raw, filename: basename(filePath, '.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,
52
66
  });
53
67
  }
54
68
  async pushFile(file, task) {
55
69
  if (!FILENAME_PATTERN.test(file.filename)) {
56
- const message = `Invalid filename "${file.filename}": only lowercase letters, digits, and hyphens are allowed (e.g. "hello-world.php")`;
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`;
57
77
  this.reportTaskFailure(message, new Error(message), task);
58
78
  }
59
79
  if (this.dryRun) {
@@ -62,9 +82,16 @@ export default class Push extends PushCommand {
62
82
  return;
63
83
  }
64
84
  try {
65
- await this.wp.put(`loopress/v1/api-files/${file.filename}`, { content: file.content });
66
- if (task)
67
- task.output = `Pushed: ${file.filename}`;
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
+ }
68
95
  }
69
96
  catch (error) {
70
97
  this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
@@ -27,12 +27,16 @@ export default class ComposerInit extends LoopressCommand {
27
27
  this.log('composer.json already exists, not overwriting (non-interactive terminal).');
28
28
  return;
29
29
  }
30
- const overwrite = await confirm({ default: false, message: 'composer.json already exists. Overwrite?' });
31
- if (!overwrite) {
30
+ const isOverwrite = await confirm({ default: false, message: 'composer.json already exists. Overwrite?' });
31
+ if (!isOverwrite) {
32
32
  this.log('Aborted.');
33
33
  return;
34
34
  }
35
35
  }
36
+ if (this.dryRun) {
37
+ this.log(`[dry-run] Would write composer.json to ${composerJsonPath}`);
38
+ return;
39
+ }
36
40
  const composerJson = {
37
41
  // composer/installers is itself a Composer plugin, and Composer 2.2+ refuses to run any
38
42
  // plugin that isn't explicitly trusted when running non-interactively (which every
@@ -46,10 +50,6 @@ export default class ComposerInit extends LoopressCommand {
46
50
  [INSTALLERS_PACKAGE]: INSTALLERS_CONSTRAINT,
47
51
  },
48
52
  };
49
- if (this.dryRun) {
50
- this.log(`[dry-run] Would write composer.json to ${composerJsonPath}`);
51
- return;
52
- }
53
53
  await writeFile(composerJsonPath, JSON.stringify(composerJson, null, 2) + '\n', 'utf8');
54
54
  this.log(`Wrote composer.json to ${composerJsonPath}`);
55
55
  this.log('Add plugins with `wpackagist-plugin/<slug>` and a theme with `wpackagist-theme/<slug>` in require, then run `lps composer push`.');
@@ -1,9 +1,15 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ type PullResult = {
3
+ status: 'dry-run' | 'success';
4
+ wroteLock: boolean;
5
+ };
2
6
  export default class ComposerPull extends LoopressCommand {
3
7
  static description: string;
8
+ static enableJsonFlag: boolean;
4
9
  static examples: string[];
5
10
  static flags: {
6
11
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
12
  };
8
- run(): Promise<void>;
13
+ run(): Promise<PullResult>;
9
14
  }
15
+ export {};
@@ -21,6 +21,7 @@ function isMissingComposerLock(error) {
21
21
  }
22
22
  export default class ComposerPull extends LoopressCommand {
23
23
  static description = 'Pull composer.json and composer.lock from WordPress';
24
+ static enableJsonFlag = true;
24
25
  static examples = ['$ lps composer pull', '$ lps composer pull --dry-run'];
25
26
  static flags = {
26
27
  ...LoopressCommand.dryRunFlag,
@@ -45,16 +46,17 @@ export default class ComposerPull extends LoopressCommand {
45
46
  }
46
47
  if (this.dryRun) {
47
48
  this.log(composerLock ? '[dry-run] Would write composer.json and composer.lock' : '[dry-run] Would write composer.json');
48
- return;
49
+ return { status: 'dry-run', wroteLock: composerLock !== undefined };
49
50
  }
50
51
  const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
51
52
  await writeFile(composerJsonPath, composerJson, 'utf8');
52
53
  if (composerLock === undefined) {
53
54
  this.log('Wrote composer.json (no composer.lock on this site yet)');
54
- return;
55
+ return { status: 'success', wroteLock: false };
55
56
  }
56
57
  const lockPath = join(process.cwd(), this.rootDir, 'composer.lock');
57
58
  await writeFile(lockPath, composerLock, 'utf8');
58
59
  this.log(`Wrote composer.json and composer.lock`);
60
+ return { status: 'success', wroteLock: true };
59
61
  }
60
62
  }
@@ -1,10 +1,17 @@
1
1
  import { PushCommand } from '../../lib/push-command.js';
2
+ type PushResult = {
3
+ hasLock: boolean;
4
+ packageCount: number;
5
+ status: 'dry-run' | 'success';
6
+ };
2
7
  export default class ComposerPush extends PushCommand {
3
8
  static description: string;
9
+ static enableJsonFlag: boolean;
4
10
  static examples: string[];
5
11
  static flags: {
6
12
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
13
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
14
  };
9
- run(): Promise<void>;
15
+ run(): Promise<PushResult>;
10
16
  }
17
+ export {};
@@ -7,6 +7,7 @@ import { isTimeoutError } from '../../lib/wp-client.js';
7
7
  const COMPOSER_SYNC_TIMEOUT_MS = 600_000;
8
8
  export default class ComposerPush extends PushCommand {
9
9
  static description = 'Push composer.json and composer.lock to WordPress and run composer install';
10
+ static enableJsonFlag = true;
10
11
  static examples = ['$ lps composer push', '$ lps composer push --dry-run'];
11
12
  static flags = {
12
13
  ...PushCommand.dryRunFlag,
@@ -32,7 +33,7 @@ export default class ComposerPush extends PushCommand {
32
33
  this.warn('No composer.lock found. The server will resolve versions freely.');
33
34
  }
34
35
  if (this.dryRun)
35
- return;
36
+ return { hasLock, packageCount, status: 'dry-run' };
36
37
  this.log('Running composer install on the server, this can take a few minutes...');
37
38
  try {
38
39
  await this.wp.post('loopress/v1/composer/sync', {
@@ -48,5 +49,6 @@ export default class ComposerPush extends PushCommand {
48
49
  }
49
50
  this.log('composer install completed on the server.');
50
51
  await this.recordSuccess();
52
+ return { hasLock, packageCount, status: 'success' };
51
53
  }
52
54
  }
@@ -0,0 +1,13 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Dev extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ only: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ skip: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ };
9
+ run(): Promise<void>;
10
+ private parseTypeFlag;
11
+ private pushBatch;
12
+ private watch;
13
+ }