@loopress/cli 0.17.0 → 0.19.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 (68) hide show
  1. package/README.md +266 -59
  2. package/dist/commands/acf/pull.d.ts +1 -1
  3. package/dist/commands/acf/pull.js +12 -25
  4. package/dist/commands/acf/push.d.ts +1 -1
  5. package/dist/commands/acf/push.js +18 -47
  6. package/dist/commands/api/list.d.ts +9 -0
  7. package/dist/commands/api/list.js +26 -0
  8. package/dist/commands/api/pull.d.ts +13 -0
  9. package/dist/commands/api/pull.js +49 -0
  10. package/dist/commands/api/push.d.ts +15 -0
  11. package/dist/commands/api/push.js +70 -0
  12. package/dist/commands/composer/init.js +11 -1
  13. package/dist/commands/composer/push.d.ts +1 -0
  14. package/dist/commands/composer/push.js +17 -4
  15. package/dist/commands/doctor.d.ts +8 -0
  16. package/dist/commands/doctor.js +79 -0
  17. package/dist/commands/form/list.d.ts +9 -0
  18. package/dist/commands/form/list.js +26 -0
  19. package/dist/commands/form/pull.d.ts +13 -0
  20. package/dist/commands/form/pull.js +60 -0
  21. package/dist/commands/form/push.d.ts +16 -0
  22. package/dist/commands/form/push.js +122 -0
  23. package/dist/commands/init.d.ts +2 -0
  24. package/dist/commands/init.js +64 -33
  25. package/dist/commands/plugin/push.d.ts +1 -1
  26. package/dist/commands/plugin/push.js +2 -14
  27. package/dist/commands/project/config.js +4 -0
  28. package/dist/commands/project/push.d.ts +5 -0
  29. package/dist/commands/project/push.js +21 -11
  30. package/dist/commands/seo/pull.d.ts +1 -3
  31. package/dist/commands/seo/pull.js +15 -43
  32. package/dist/commands/seo/push.d.ts +1 -1
  33. package/dist/commands/seo/push.js +4 -17
  34. package/dist/commands/snippet/publish.js +2 -2
  35. package/dist/commands/snippet/pull.d.ts +1 -4
  36. package/dist/commands/snippet/pull.js +12 -60
  37. package/dist/commands/snippet/push.d.ts +1 -1
  38. package/dist/commands/snippet/push.js +4 -13
  39. package/dist/commands/status.d.ts +4 -0
  40. package/dist/commands/status.js +25 -4
  41. package/dist/config/project-config.manager.js +2 -2
  42. package/dist/lib/api-client.d.ts +2 -1
  43. package/dist/lib/api-client.js +10 -3
  44. package/dist/lib/base.d.ts +11 -0
  45. package/dist/lib/base.js +71 -2
  46. package/dist/lib/find-orphaned-files.d.ts +7 -0
  47. package/dist/lib/find-orphaned-files.js +29 -0
  48. package/dist/lib/interactive.d.ts +1 -0
  49. package/dist/lib/interactive.js +7 -0
  50. package/dist/lib/load-files.d.ts +5 -0
  51. package/dist/lib/load-files.js +31 -0
  52. package/dist/lib/push-command.d.ts +7 -0
  53. package/dist/lib/push-command.js +40 -2
  54. package/dist/lib/wp-client.d.ts +9 -4
  55. package/dist/lib/wp-client.js +26 -12
  56. package/dist/types/project-config.generated.d.ts +8 -0
  57. package/dist/utils/composer.d.ts +3 -0
  58. package/dist/utils/form-format.d.ts +3 -0
  59. package/dist/utils/form-format.js +17 -0
  60. package/dist/utils/seo-format.d.ts +1 -0
  61. package/dist/utils/seo-format.js +5 -0
  62. package/dist/utils/snippet-format.d.ts +2 -0
  63. package/dist/utils/snippet-format.js +27 -1
  64. package/dist/utils/to-slug.d.ts +1 -0
  65. package/dist/utils/to-slug.js +7 -0
  66. package/oclif.manifest.json +748 -96
  67. package/package.json +26 -17
  68. package/schemas/project-config.schema.json +10 -0
@@ -7,9 +7,9 @@ export default class Pull extends LoopressCommand {
7
7
  static examples: string[];
8
8
  static flags: {
9
9
  type: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
12
  };
12
13
  run(): Promise<void>;
13
- private findOrphanedFiles;
14
14
  private pullType;
15
15
  }
@@ -1,8 +1,9 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { Listr } from 'listr2';
3
- import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
4
- import { extname, join } from 'node:path';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
5
  import { LoopressCommand } from '../../lib/base.js';
6
+ import { basenameKey, findOrphanedFiles } from '../../lib/find-orphaned-files.js';
6
7
  import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../../utils/acf-format.js';
7
8
  export default class Pull extends LoopressCommand {
8
9
  static args = {
@@ -12,6 +13,7 @@ export default class Pull extends LoopressCommand {
12
13
  static examples = ['$ lps acf pull', '$ lps acf pull --type field-groups'];
13
14
  static flags = {
14
15
  ...LoopressCommand.dryRunFlag,
16
+ ...LoopressCommand.yesFlag,
15
17
  type: Flags.string({ description: 'Limit to specific ACF object types', multiple: true, options: ACF_OBJECT_TYPES }),
16
18
  };
17
19
  async run() {
@@ -25,28 +27,18 @@ export default class Pull extends LoopressCommand {
25
27
  await this.pullType(type, path);
26
28
  }
27
29
  }
28
- // Every file in a type's subdirectory is unambiguously `<key>.json` — unlike snippets, ACF
29
- // objects have no numeric-id/slug filename convention. `key` is the stable identity ACF
30
- // itself already uses for this (see its own Local JSON mechanism), so orphan detection is a
31
- // plain set difference, no regex/extension juggling needed.
32
- async findOrphanedFiles(dir, keepKeys) {
33
- let files;
34
- try {
35
- files = await readdir(dir);
36
- }
37
- catch (error) {
38
- if (error.code === 'ENOENT')
39
- return [];
40
- throw error;
41
- }
42
- return files.filter((file) => extname(file) === '.json' && !keepKeys.has(file.slice(0, -'.json'.length)));
43
- }
44
30
  async pullType(type, basePath) {
45
31
  const dir = join(basePath, type);
46
32
  const remoteList = await this.wp.get(acfEndpoint(type));
47
33
  const withKey = remoteList.filter((object) => getAcfKey(object) !== null);
48
34
  const skipped = remoteList.length - withKey.length;
49
- const orphans = await this.findOrphanedFiles(dir, new Set(withKey.map((object) => getAcfKey(object))));
35
+ // Every file in a type's subdirectory is unambiguously `<key>.json`: `key` is the stable
36
+ // identity ACF itself already uses (see its own Local JSON mechanism), no numeric-id/slug
37
+ // filename convention like snippets.
38
+ const orphans = await findOrphanedFiles(dir, new Set(withKey.map((object) => getAcfKey(object))), {
39
+ extensions: ['.json'],
40
+ key: basenameKey,
41
+ });
50
42
  if (this.dryRun) {
51
43
  this.log(`[dry-run] Would pull ${withKey.length} ${type} to ${dir}`);
52
44
  if (orphans.length > 0) {
@@ -66,12 +58,7 @@ export default class Pull extends LoopressCommand {
66
58
  title: `Pull ${key}`,
67
59
  };
68
60
  })).run();
69
- for (const file of orphans) {
70
- await rm(join(dir, file), { force: true });
71
- }
72
- if (orphans.length > 0) {
73
- this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${dir} no longer present on WordPress: ${orphans.join(', ')}`);
74
- }
61
+ await this.removeOrphanedFiles(dir, orphans, `in ${dir} no longer present on WordPress`);
75
62
  this.log(`Pulled ${withKey.length} ${type} to ${dir}`);
76
63
  if (skipped > 0) {
77
64
  this.warn(`${skipped} ${type} skipped because they have no key`);
@@ -7,9 +7,9 @@ export default class Push extends PushCommand {
7
7
  static examples: string[];
8
8
  static flags: {
9
9
  type: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
12
  };
12
- private failedCount;
13
13
  run(): Promise<void>;
14
14
  private loadObjects;
15
15
  private pushObject;
@@ -1,7 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { Listr } from 'listr2';
3
- import { readdir, readFile } from 'node:fs/promises';
4
- import { extname, join } from 'node:path';
3
+ import { join } from 'node:path';
4
+ import { loadFiles } from '../../lib/load-files.js';
5
5
  import { PushCommand } from '../../lib/push-command.js';
6
6
  import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../../utils/acf-format.js';
7
7
  export default class Push extends PushCommand {
@@ -12,9 +12,9 @@ export default class Push extends PushCommand {
12
12
  static examples = ['$ lps acf push', '$ lps acf push --type field-groups'];
13
13
  static flags = {
14
14
  ...PushCommand.dryRunFlag,
15
+ ...PushCommand.yesFlag,
15
16
  type: Flags.string({ description: 'Limit to specific ACF object types', multiple: true, options: ACF_OBJECT_TYPES }),
16
17
  };
17
- failedCount = 0;
18
18
  async run() {
19
19
  const { args, flags } = await this.parse(Push);
20
20
  const { url } = this.siteConfig;
@@ -33,45 +33,22 @@ export default class Push extends PushCommand {
33
33
  await this.recordSuccess();
34
34
  this.log('All ACF objects pushed.');
35
35
  }
36
- // One object's file is read in isolation: a corrupted or hand-broken JSON file (or one
37
- // missing/emptying its "key") must only skip that object, not abort loading the rest of
38
- // the type's directory — same principle as loadSnippets() for the snippet feature.
39
36
  async loadObjects(dir) {
40
- let files;
41
- try {
42
- files = await readdir(dir);
43
- }
44
- catch (error) {
45
- if (error.code === 'ENOENT')
46
- return [];
47
- throw error;
48
- }
49
- const objects = [];
50
- for (const file of files) {
51
- if (extname(file) !== '.json')
52
- continue;
53
- const filePath = join(dir, file);
54
- let parsed;
55
- try {
56
- parsed = JSON.parse(await readFile(filePath, 'utf8'));
57
- }
58
- catch (error) {
59
- this.warn(`Skipping "${filePath}": ${error.message}`);
60
- continue;
61
- }
62
- if (typeof parsed !== 'object' || parsed === null || getAcfKey(parsed) === null) {
63
- this.warn(`Skipping "${filePath}": missing or invalid "key"`);
64
- continue;
65
- }
66
- objects.push(parsed);
67
- }
68
- return objects;
37
+ return loadFiles(dir, {
38
+ extension: '.json',
39
+ onSkip: (message) => this.warn(message),
40
+ parse(raw) {
41
+ const parsed = JSON.parse(raw);
42
+ if (typeof parsed !== 'object' || parsed === null || getAcfKey(parsed) === null) {
43
+ throw new Error('missing or invalid "key"');
44
+ }
45
+ return parsed;
46
+ },
47
+ });
69
48
  }
70
- // Throwing on failure (rather than returning a boolean) is what lets Listr mark the task as
71
- // failed (red cross) instead of completed; `exitOnError: false` on the task list still lets
72
- // sibling objects push regardless. POST alone covers create-or-update (the controller resolves
73
- // that server-side via the object's `key`), unlike snippet push there's no numeric-id PUT/404
74
- // fallback dance, and no rename-on-push step since `key` is permanently stable.
49
+ // POST alone covers create-or-update (the controller resolves that server-side via the
50
+ // object's `key`), unlike snippet push there's no numeric-id PUT/404 fallback dance, and no
51
+ // rename-on-push step since `key` is permanently stable.
75
52
  async pushObject(type, object, task) {
76
53
  const key = getAcfKey(object) ?? '(unknown)';
77
54
  if (this.dryRun) {
@@ -85,13 +62,7 @@ export default class Push extends PushCommand {
85
62
  task.output = `Pushed: ${key}`;
86
63
  }
87
64
  catch (error) {
88
- const message = `Failed to push ${key}: ${error.message}`;
89
- if (task)
90
- task.output = message;
91
- else
92
- this.warn(` ${message}`);
93
- this.failedCount++;
94
- throw error;
65
+ this.reportTaskFailure(`Failed to push ${key}: ${error.message}`, error, task);
95
66
  }
96
67
  }
97
68
  async pushType(type, basePath) {
@@ -0,0 +1,9 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ export default class List extends LoopressCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ };
8
+ run(): Promise<void>;
9
+ }
@@ -0,0 +1,26 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { LoopressCommand } from '../../lib/base.js';
3
+ export default class List extends LoopressCommand {
4
+ static description = 'List custom API route files from WordPress';
5
+ static examples = ['$ lps api list'];
6
+ static flags = {
7
+ json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
8
+ };
9
+ async run() {
10
+ const { flags } = await this.parse(List);
11
+ 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
+ if (files.length === 0) {
17
+ this.log('No API route files found');
18
+ return;
19
+ }
20
+ this.log(`Found ${files.length} route file${files.length === 1 ? '' : 's'}:`);
21
+ this.log('');
22
+ for (const file of files) {
23
+ this.log(` ${file.filename}`);
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,13 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ export default class Pull extends LoopressCommand {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ };
12
+ run(): Promise<void>;
13
+ }
@@ -0,0 +1,49 @@
1
+ import { Args } from '@oclif/core';
2
+ import { Listr } from 'listr2';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { LoopressCommand } from '../../lib/base.js';
6
+ import { basenameKey, findOrphanedFiles } from '../../lib/find-orphaned-files.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 examples = ['$ lps api pull', '$ lps api pull --path ./api'];
13
+ static flags = {
14
+ ...LoopressCommand.dryRunFlag,
15
+ ...LoopressCommand.yesFlag,
16
+ };
17
+ async run() {
18
+ const { args } = await this.parse(Pull);
19
+ const { url } = this.siteConfig;
20
+ const path = this.resolveApiPath(args.path);
21
+ this.log(`Pulling API routes from ${url}`);
22
+ this.log(`API path: ${path}`);
23
+ const files = await this.wp.get('loopress/v1/api-files');
24
+ // A `<filename>.php` no longer present remotely belongs to a route deleted on WordPress
25
+ // (push itself stays additive-only, but pull already cleans up locally, same as
26
+ // `snippet pull`).
27
+ const orphans = await findOrphanedFiles(path, new Set(files.map((file) => file.filename)), {
28
+ extensions: ['.php'],
29
+ key: basenameKey,
30
+ });
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}`;
43
+ },
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
+ }
@@ -0,0 +1,15 @@
1
+ import { PushCommand } from '../../lib/push-command.js';
2
+ export default class Push extends PushCommand {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ };
12
+ run(): Promise<void>;
13
+ private loadFiles;
14
+ private pushFile;
15
+ }
@@ -0,0 +1,70 @@
1
+ import { Args } from '@oclif/core';
2
+ import { Listr } from 'listr2';
3
+ import { basename } from 'node:path';
4
+ import { loadFiles as loadDirectoryFiles } from '../../lib/load-files.js';
5
+ import { PushCommand } from '../../lib/push-command.js';
6
+ // Mirrors the server's own allowlist (wordpress-plugin ApiFilesController::FILENAME_PATTERN):
7
+ // the filename becomes a URL path segment matched against this exact regex by the WP REST
8
+ // route itself. A filename that doesn't match never reaches the controller, WordPress's
9
+ // router returns a generic 404 before validate_callback runs, which the CLI's shared error
10
+ // formatter reports as "is the plugin installed?", a confusing message for what is actually
11
+ // an invalid filename. Checking client-side first turns that into an accurate error and
12
+ // skips a network round-trip that could only ever fail.
13
+ const FILENAME_PATTERN = /^[a-z0-9-]+$/;
14
+ export default class Push extends PushCommand {
15
+ static args = {
16
+ path: Args.string({ description: 'Path to api directory (overrides project config)' }),
17
+ };
18
+ static description = 'Push custom API route files to WordPress';
19
+ static examples = ['$ lps api push', '$ lps api push --path ./api'];
20
+ static flags = {
21
+ ...PushCommand.dryRunFlag,
22
+ ...PushCommand.yesFlag,
23
+ };
24
+ async run() {
25
+ const { args } = await this.parse(Push);
26
+ const { url } = this.siteConfig;
27
+ const path = this.resolveApiPath(args.path);
28
+ this.log(`Pushing API routes to ${url}`);
29
+ this.log(`API path: ${path}`);
30
+ const files = await this.loadFiles(path);
31
+ this.log(`Found ${files.length} route file${files.length === 1 ? '' : 's'} to push`);
32
+ await new Listr(files.map((file) => ({
33
+ task: async (_ctx, task) => this.pushFile(file, task),
34
+ title: `Push ${file.filename}`,
35
+ })), { concurrent: false, exitOnError: false }).run();
36
+ if (this.failedCount > 0) {
37
+ this.error(`${this.failedCount} route file${this.failedCount === 1 ? '' : 's'} failed to push.`);
38
+ }
39
+ if (this.dryRun)
40
+ return;
41
+ await this.recordSuccess();
42
+ this.log('All API routes pushed.');
43
+ }
44
+ async loadFiles(path) {
45
+ return loadDirectoryFiles(path, {
46
+ extension: '.php',
47
+ onSkip: (message) => this.warn(message),
48
+ parse: (raw, filePath) => ({ content: raw, filename: basename(filePath, '.php') }),
49
+ });
50
+ }
51
+ async pushFile(file, task) {
52
+ if (!FILENAME_PATTERN.test(file.filename)) {
53
+ const message = `Invalid filename "${file.filename}": only lowercase letters, digits, and hyphens are allowed (e.g. "hello-world.php")`;
54
+ this.reportTaskFailure(message, new Error(message), task);
55
+ }
56
+ if (this.dryRun) {
57
+ if (task)
58
+ task.output = `[dry-run] Would push: ${file.filename}`;
59
+ return;
60
+ }
61
+ try {
62
+ await this.wp.put(`loopress/v1/api-files/${file.filename}`, { content: file.content });
63
+ if (task)
64
+ task.output = `Pushed: ${file.filename}`;
65
+ }
66
+ catch (error) {
67
+ this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
68
+ }
69
+ }
70
+ }
@@ -3,11 +3,12 @@ import { existsSync } from 'node:fs';
3
3
  import { writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { LoopressCommand } from '../../lib/base.js';
6
+ import { isInteractive } from '../../lib/interactive.js';
6
7
  const WPACKAGIST_REPOSITORY = { type: 'composer', url: 'https://wpackagist.org' };
7
8
  const INSTALLERS_PACKAGE = 'composer/installers';
8
9
  const INSTALLERS_CONSTRAINT = '^2.0';
9
10
  // The server runs Composer with `--working-dir` set to wp-content/loopress/ (see
10
- // LoopressEnvironment::getDxDir), not the WordPress root, so installer-paths must climb out
11
+ // LoopressEnvironment::getLoopressDir), not the WordPress root, so installer-paths must climb out
11
12
  // of that directory to land plugins/themes in their usual wp-content/ locations.
12
13
  const INSTALLER_PATHS = {
13
14
  '../plugins/{$name}/': ['type:wordpress-plugin'],
@@ -22,6 +23,10 @@ export default class ComposerInit extends LoopressCommand {
22
23
  async run() {
23
24
  const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
24
25
  if (existsSync(composerJsonPath)) {
26
+ if (!isInteractive()) {
27
+ this.log('composer.json already exists, not overwriting (non-interactive terminal).');
28
+ return;
29
+ }
25
30
  const overwrite = await confirm({ default: false, message: 'composer.json already exists. Overwrite?' });
26
31
  if (!overwrite) {
27
32
  this.log('Aborted.');
@@ -29,6 +34,11 @@ export default class ComposerInit extends LoopressCommand {
29
34
  }
30
35
  }
31
36
  const composerJson = {
37
+ // composer/installers is itself a Composer plugin, and Composer 2.2+ refuses to run any
38
+ // plugin that isn't explicitly trusted when running non-interactively (which every
39
+ // `composer push` on the server does). Without this, the very first real push against
40
+ // this scaffold 500s on Composer's own plugin-trust gate before ever touching a package.
41
+ config: { 'allow-plugins': { [INSTALLERS_PACKAGE]: true } },
32
42
  extra: { 'installer-paths': INSTALLER_PATHS },
33
43
  name: 'loopress/site-dependencies',
34
44
  repositories: [WPACKAGIST_REPOSITORY],
@@ -3,6 +3,7 @@ export default class ComposerPush extends PushCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
6
7
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
8
  };
8
9
  run(): Promise<void>;
@@ -2,11 +2,15 @@ import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { PushCommand } from '../../lib/push-command.js';
5
+ import { isTimeoutError } from '../../lib/wp-client.js';
6
+ // A cold composer install easily exceeds the global 30s timeout of WpClient.
7
+ const COMPOSER_SYNC_TIMEOUT_MS = 600_000;
5
8
  export default class ComposerPush extends PushCommand {
6
9
  static description = 'Push composer.json and composer.lock to WordPress and run composer install';
7
10
  static examples = ['$ lps composer push', '$ lps composer push --dry-run'];
8
11
  static flags = {
9
12
  ...PushCommand.dryRunFlag,
13
+ ...PushCommand.yesFlag,
10
14
  };
11
15
  async run() {
12
16
  const { url } = this.siteConfig;
@@ -29,10 +33,19 @@ export default class ComposerPush extends PushCommand {
29
33
  }
30
34
  if (this.dryRun)
31
35
  return;
32
- await this.wp.post('loopress/v1/composer/sync', {
33
- composerJson: composerJsonRaw,
34
- composerLock: composerLockRaw,
35
- });
36
+ this.log('Running composer install on the server, this can take a few minutes...');
37
+ try {
38
+ await this.wp.post('loopress/v1/composer/sync', {
39
+ composerJson: composerJsonRaw,
40
+ composerLock: composerLockRaw,
41
+ }, { timeoutMs: COMPOSER_SYNC_TIMEOUT_MS });
42
+ }
43
+ catch (error) {
44
+ if (isTimeoutError(error)) {
45
+ this.error(`${error.message} The composer install may still be running on the server.`);
46
+ }
47
+ throw error;
48
+ }
36
49
  this.log('composer install completed on the server.');
37
50
  await this.recordSuccess();
38
51
  }
@@ -0,0 +1,8 @@
1
+ import { LoopressCommand } from '../lib/base.js';
2
+ export default class Doctor extends LoopressCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ run(): Promise<void>;
6
+ private check;
7
+ private reportPluginVersion;
8
+ }
@@ -0,0 +1,79 @@
1
+ import { configManager } from '../config/project-config.manager.js';
2
+ import { LoopressCommand } from '../lib/base.js';
3
+ import { isNotFoundError } from '../lib/wp-client.js';
4
+ import { diagnoseWpSite } from '../lib/wp-site-diagnostic.js';
5
+ export default class Doctor extends LoopressCommand {
6
+ static description = 'Diagnose connectivity, plugin and credential problems for the targeted environment';
7
+ static examples = ['$ lps doctor', '$ lps doctor --env production'];
8
+ async run() {
9
+ const { name, token, url } = this.siteConfig;
10
+ const project = this.localConfig.projectId
11
+ ? configManager.getProject(this.localConfig.projectId)
12
+ : configManager.getCurrentProject();
13
+ this.log(`Project: ${project?.name ?? '(unknown)'}`);
14
+ this.log(`Environment: ${name}`);
15
+ this.log(`URL: ${url}`);
16
+ this.log('');
17
+ let failed = 0;
18
+ const reachable = await this.check('WordPress REST API reachable', async () => {
19
+ const diagnostic = await diagnoseWpSite(url);
20
+ if (!diagnostic.ok)
21
+ throw new Error(diagnostic.reason);
22
+ });
23
+ if (!reachable) {
24
+ failed++;
25
+ this.log('- Remaining checks skipped while the site is unreachable.');
26
+ this.error('1 check failed.', { exit: 1 });
27
+ }
28
+ if (!token) {
29
+ this.log('✗ Credentials configured');
30
+ this.log(` No credentials stored for ${url}. Run \`lps project config\` to add them.`);
31
+ this.log('- Remaining checks skipped without credentials.');
32
+ this.error('1 check failed.', { exit: 1 });
33
+ }
34
+ // The namespace index answers regardless of which Loopress features are active, so a 404
35
+ // here can only mean the plugin itself is missing or outdated (WpClient's 404 message
36
+ // already says exactly that).
37
+ if (!(await this.check('Loopress plugin installed (loopress/v1 endpoints)', () => this.wp.get('loopress/v1')))) {
38
+ failed++;
39
+ }
40
+ // wp/v2/users/me is WordPress core and requires authentication, so it validates the
41
+ // application password without depending on any Loopress feature.
42
+ if (!(await this.check('Credentials accepted (authenticated request)', () => this.wp.get('wp/v2/users/me')))) {
43
+ failed++;
44
+ }
45
+ await this.reportPluginVersion();
46
+ this.log('');
47
+ if (failed > 0) {
48
+ this.error(`${failed} check${failed === 1 ? '' : 's'} failed.`, { exit: 1 });
49
+ }
50
+ this.log('All checks passed.');
51
+ }
52
+ async check(title, run) {
53
+ try {
54
+ await run();
55
+ this.log(`✓ ${title}`);
56
+ return true;
57
+ }
58
+ catch (error) {
59
+ this.log(`✗ ${title}`);
60
+ this.log(` ${error.message}`);
61
+ return false;
62
+ }
63
+ }
64
+ // Informational only: Loopress Light does not expose loopress/v1/update, and an old plugin
65
+ // predates it, neither should flip the doctor to a failure when everything else works.
66
+ async reportPluginVersion() {
67
+ try {
68
+ const status = await this.wp.get('loopress/v1/update');
69
+ this.log(`✓ Plugin version: ${status.current_version ?? '(unknown)'}`);
70
+ }
71
+ catch (error) {
72
+ if (isNotFoundError(error)) {
73
+ this.log('- Plugin version: not exposed by this plugin edition.');
74
+ return;
75
+ }
76
+ this.log(`- Plugin version: could not be read. ${error.message}`);
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,9 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ export default class List extends LoopressCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ };
8
+ run(): Promise<void>;
9
+ }
@@ -0,0 +1,26 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { LoopressCommand } from '../../lib/base.js';
3
+ import { FORM_ENDPOINT, getFormId, getFormTitle } from '../../utils/form-format.js';
4
+ export default class List extends LoopressCommand {
5
+ static description = 'List forms from WordPress';
6
+ static examples = ['$ lps form list'];
7
+ static flags = {
8
+ json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
9
+ };
10
+ async run() {
11
+ const { flags } = await this.parse(List);
12
+ const forms = await this.wp.get(FORM_ENDPOINT);
13
+ if (flags.json) {
14
+ this.log(JSON.stringify(forms, null, 2));
15
+ return;
16
+ }
17
+ this.log(`Forms (${forms.length}):`);
18
+ if (forms.length === 0) {
19
+ this.log(' (none)');
20
+ return;
21
+ }
22
+ for (const form of forms) {
23
+ this.log(` ${getFormId(form) ?? '(no id)'}. ${getFormTitle(form)}`);
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,13 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ export default class Pull extends LoopressCommand {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ };
12
+ run(): Promise<void>;
13
+ }