@loopress/cli 0.18.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 (60) hide show
  1. package/README.md +138 -74
  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/pull.d.ts +1 -1
  7. package/dist/commands/api/pull.js +11 -26
  8. package/dist/commands/api/push.d.ts +1 -1
  9. package/dist/commands/api/push.js +21 -27
  10. package/dist/commands/composer/init.js +11 -1
  11. package/dist/commands/composer/push.d.ts +1 -0
  12. package/dist/commands/composer/push.js +17 -4
  13. package/dist/commands/doctor.d.ts +8 -0
  14. package/dist/commands/doctor.js +79 -0
  15. package/dist/commands/form/pull.d.ts +1 -2
  16. package/dist/commands/form/pull.js +13 -32
  17. package/dist/commands/form/push.d.ts +1 -1
  18. package/dist/commands/form/push.js +6 -14
  19. package/dist/commands/init.d.ts +2 -0
  20. package/dist/commands/init.js +64 -33
  21. package/dist/commands/plugin/push.d.ts +1 -1
  22. package/dist/commands/plugin/push.js +2 -14
  23. package/dist/commands/project/config.js +4 -0
  24. package/dist/commands/project/push.d.ts +5 -0
  25. package/dist/commands/project/push.js +21 -11
  26. package/dist/commands/seo/pull.d.ts +1 -3
  27. package/dist/commands/seo/pull.js +15 -43
  28. package/dist/commands/seo/push.d.ts +1 -1
  29. package/dist/commands/seo/push.js +4 -17
  30. package/dist/commands/snippet/publish.js +2 -2
  31. package/dist/commands/snippet/pull.d.ts +1 -4
  32. package/dist/commands/snippet/pull.js +12 -60
  33. package/dist/commands/snippet/push.d.ts +1 -1
  34. package/dist/commands/snippet/push.js +4 -13
  35. package/dist/commands/status.d.ts +4 -0
  36. package/dist/commands/status.js +25 -4
  37. package/dist/config/project-config.manager.js +2 -2
  38. package/dist/lib/api-client.d.ts +2 -1
  39. package/dist/lib/api-client.js +10 -3
  40. package/dist/lib/base.d.ts +9 -0
  41. package/dist/lib/base.js +61 -2
  42. package/dist/lib/find-orphaned-files.d.ts +7 -0
  43. package/dist/lib/find-orphaned-files.js +29 -0
  44. package/dist/lib/interactive.d.ts +1 -0
  45. package/dist/lib/interactive.js +7 -0
  46. package/dist/lib/load-files.d.ts +5 -0
  47. package/dist/lib/load-files.js +31 -0
  48. package/dist/lib/push-command.d.ts +7 -0
  49. package/dist/lib/push-command.js +40 -2
  50. package/dist/lib/wp-client.d.ts +9 -4
  51. package/dist/lib/wp-client.js +26 -12
  52. package/dist/utils/composer.d.ts +3 -0
  53. package/dist/utils/seo-format.d.ts +1 -0
  54. package/dist/utils/seo-format.js +5 -0
  55. package/dist/utils/snippet-format.d.ts +2 -0
  56. package/dist/utils/snippet-format.js +27 -1
  57. package/dist/utils/to-slug.d.ts +1 -0
  58. package/dist/utils/to-slug.js +7 -0
  59. package/oclif.manifest.json +469 -61
  60. package/package.json +10 -4
@@ -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) {
@@ -6,8 +6,8 @@ export default class Pull extends LoopressCommand {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  };
11
12
  run(): Promise<void>;
12
- private findOrphanedFiles;
13
13
  }
@@ -1,8 +1,9 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { Listr } from 'listr2';
3
- import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
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
  export default class Pull extends LoopressCommand {
7
8
  static args = {
8
9
  path: Args.string({ description: 'Path to api directory (overrides project config)' }),
@@ -11,6 +12,7 @@ export default class Pull extends LoopressCommand {
11
12
  static examples = ['$ lps api pull', '$ lps api pull --path ./api'];
12
13
  static flags = {
13
14
  ...LoopressCommand.dryRunFlag,
15
+ ...LoopressCommand.yesFlag,
14
16
  };
15
17
  async run() {
16
18
  const { args } = await this.parse(Pull);
@@ -19,11 +21,13 @@ export default class Pull extends LoopressCommand {
19
21
  this.log(`Pulling API routes from ${url}`);
20
22
  this.log(`API path: ${path}`);
21
23
  const files = await this.wp.get('loopress/v1/api-files');
22
- // Files following the `<filename>.php` convention no longer present remotely belong to
23
- // a route deleted on WordPress. Left on disk, they'd silently come back to life the next
24
- // time `api push` runs (see obsidian/Product/Push Deletion Rules.md: push itself stays
25
- // additive-only, but pull already cleans up locally, same as `snippet pull`).
26
- const orphans = await this.findOrphanedFiles(path, new Set(files.map((file) => file.filename)));
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
+ });
27
31
  if (this.dryRun) {
28
32
  this.log(`[dry-run] Would pull ${files.length} route file${files.length === 1 ? '' : 's'} to ${path}`);
29
33
  if (orphans.length > 0) {
@@ -39,26 +43,7 @@ export default class Pull extends LoopressCommand {
39
43
  },
40
44
  title: `Pull ${file.filename}`,
41
45
  }))).run();
42
- for (const file of orphans)
43
- await rm(join(path, file), { force: true });
44
- if (orphans.length > 0) {
45
- this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} whose route no longer exists on WordPress: ${orphans.join(', ')}`);
46
- }
46
+ await this.removeOrphanedFiles(path, orphans, 'whose route no longer exists on WordPress');
47
47
  this.log(`Pulled ${files.length} route file${files.length === 1 ? '' : 's'} to ${path}`);
48
48
  }
49
- // Only ever matches `.php` files already following the flat `<filename>.php` convention
50
- // that `api pull`/`push` themselves produce, so a hand-created non-.php file is never at
51
- // risk of being picked up here.
52
- async findOrphanedFiles(path, keepFilenames) {
53
- let entries;
54
- try {
55
- entries = await readdir(path);
56
- }
57
- catch (error) {
58
- if (error.code === 'ENOENT')
59
- return [];
60
- throw error;
61
- }
62
- return entries.filter((entry) => entry.endsWith('.php') && !keepFilenames.has(entry.slice(0, -4)));
63
- }
64
49
  }
@@ -6,9 +6,9 @@ export default class Push extends PushCommand {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  };
11
- private failedCount;
12
12
  run(): Promise<void>;
13
13
  private loadFiles;
14
14
  private pushFile;
@@ -1,8 +1,16 @@
1
1
  import { Args } 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 { basename } from 'node:path';
4
+ import { loadFiles as loadDirectoryFiles } from '../../lib/load-files.js';
5
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-]+$/;
6
14
  export default class Push extends PushCommand {
7
15
  static args = {
8
16
  path: Args.string({ description: 'Path to api directory (overrides project config)' }),
@@ -11,8 +19,8 @@ export default class Push extends PushCommand {
11
19
  static examples = ['$ lps api push', '$ lps api push --path ./api'];
12
20
  static flags = {
13
21
  ...PushCommand.dryRunFlag,
22
+ ...PushCommand.yesFlag,
14
23
  };
15
- failedCount = 0;
16
24
  async run() {
17
25
  const { args } = await this.parse(Push);
18
26
  const { url } = this.siteConfig;
@@ -34,25 +42,17 @@ export default class Push extends PushCommand {
34
42
  this.log('All API routes pushed.');
35
43
  }
36
44
  async loadFiles(path) {
37
- let entries;
38
- try {
39
- entries = await readdir(path);
40
- }
41
- catch (error) {
42
- if (error.code === 'ENOENT')
43
- return [];
44
- throw error;
45
- }
46
- const files = [];
47
- for (const entry of entries) {
48
- if (extname(entry) !== '.php')
49
- continue;
50
- const content = await readFile(join(path, entry), 'utf8');
51
- files.push({ content, filename: entry.slice(0, -4) });
52
- }
53
- return files;
45
+ return loadDirectoryFiles(path, {
46
+ extension: '.php',
47
+ onSkip: (message) => this.warn(message),
48
+ parse: (raw, filePath) => ({ content: raw, filename: basename(filePath, '.php') }),
49
+ });
54
50
  }
55
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
56
  if (this.dryRun) {
57
57
  if (task)
58
58
  task.output = `[dry-run] Would push: ${file.filename}`;
@@ -64,13 +64,7 @@ export default class Push extends PushCommand {
64
64
  task.output = `Pushed: ${file.filename}`;
65
65
  }
66
66
  catch (error) {
67
- const message = `Failed to push ${file.filename}: ${error.message}`;
68
- if (task)
69
- task.output = message;
70
- else
71
- this.warn(` ${message}`);
72
- this.failedCount++;
73
- throw error;
67
+ this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
74
68
  }
75
69
  }
76
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
+ }
@@ -6,9 +6,8 @@ export default class Pull extends LoopressCommand {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  };
11
12
  run(): Promise<void>;
12
- private findOrphanedFiles;
13
13
  }
14
- export declare function slug(title: string): string;
@@ -1,10 +1,11 @@
1
1
  import { Args } 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';
5
- import slugify from 'slugify';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
6
5
  import { LoopressCommand } from '../../lib/base.js';
6
+ import { findOrphanedFiles, numericPrefixKey } from '../../lib/find-orphaned-files.js';
7
7
  import { FORM_ENDPOINT, getFormId, getFormTitle } from '../../utils/form-format.js';
8
+ import { toSlug } from '../../utils/to-slug.js';
8
9
  export default class Pull extends LoopressCommand {
9
10
  static args = {
10
11
  path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
@@ -13,6 +14,7 @@ export default class Pull extends LoopressCommand {
13
14
  static examples = ['$ lps form pull'];
14
15
  static flags = {
15
16
  ...LoopressCommand.dryRunFlag,
17
+ ...LoopressCommand.yesFlag,
16
18
  };
17
19
  async run() {
18
20
  const { args } = await this.parse(Pull);
@@ -23,7 +25,12 @@ export default class Pull extends LoopressCommand {
23
25
  const remoteList = await this.wp.get(FORM_ENDPOINT);
24
26
  const withId = remoteList.filter((form) => getFormId(form) !== null);
25
27
  const skipped = remoteList.length - withId.length;
26
- const orphans = await this.findOrphanedFiles(path, new Set(withId.map((form) => getFormId(form))));
28
+ // Only matches files following the `<id>-<slug>.json` convention pull/push themselves
29
+ // produce, same principle as `snippet pull`.
30
+ const orphans = await findOrphanedFiles(path, new Set(withId.map((form) => String(getFormId(form)))), {
31
+ extensions: ['.json'],
32
+ key: numericPrefixKey,
33
+ });
27
34
  if (this.dryRun) {
28
35
  this.log(`[dry-run] Would pull ${withId.length} form${withId.length === 1 ? '' : 's'} to ${path}`);
29
36
  if (orphans.length > 0) {
@@ -38,42 +45,16 @@ export default class Pull extends LoopressCommand {
38
45
  const title = getFormTitle(form);
39
46
  return {
40
47
  async task(_ctx, task) {
41
- await writeFile(join(path, `${id}-${slug(title)}.json`), JSON.stringify(form, null, 2) + '\n');
48
+ await writeFile(join(path, `${id}-${toSlug(title, 'untitled')}.json`), JSON.stringify(form, null, 2) + '\n');
42
49
  task.output = `Pulled: ${title}`;
43
50
  },
44
51
  title: `Pull ${title}`,
45
52
  };
46
53
  })).run();
47
- for (const file of orphans)
48
- await rm(join(path, file), { force: true });
49
- if (orphans.length > 0) {
50
- this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${path} no longer present on WordPress: ${orphans.join(', ')}`);
51
- }
54
+ await this.removeOrphanedFiles(path, orphans, `in ${path} no longer present on WordPress`);
52
55
  this.log(`Pulled ${withId.length} form${withId.length === 1 ? '' : 's'} to ${path}`);
53
56
  if (skipped > 0) {
54
57
  this.warn(`${skipped} form${skipped === 1 ? '' : 's'} skipped because they have no id`);
55
58
  }
56
59
  }
57
- // Only matches files following the `<id>-<slug>.json` convention pull/push themselves
58
- // produce, same principle as findOrphanedFiles in commands/snippet/pull.ts.
59
- async findOrphanedFiles(dir, keepIds) {
60
- let files;
61
- try {
62
- files = await readdir(dir);
63
- }
64
- catch (error) {
65
- if (error.code === 'ENOENT')
66
- return [];
67
- throw error;
68
- }
69
- return files.filter((file) => {
70
- if (extname(file) !== '.json')
71
- return false;
72
- const match = /^(\d+)-/.exec(file);
73
- return match !== null && !keepIds.has(Number(match[1]));
74
- });
75
- }
76
- }
77
- export function slug(title) {
78
- return slugify(title, { lower: true, strict: true }) || 'untitled';
79
60
  }
@@ -6,9 +6,9 @@ export default class Push extends PushCommand {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  };
11
- private failedCount;
12
12
  run(): Promise<void>;
13
13
  private ensureCanonicalFilename;
14
14
  private loadFiles;