@loopress/cli 0.18.0 → 0.20.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 (87) hide show
  1. package/README.md +260 -75
  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/publish.d.ts +9 -0
  7. package/dist/commands/api/publish.js +60 -0
  8. package/dist/commands/api/pull.d.ts +1 -1
  9. package/dist/commands/api/pull.js +11 -26
  10. package/dist/commands/api/push.d.ts +2 -1
  11. package/dist/commands/api/push.js +40 -27
  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/pull.d.ts +1 -2
  18. package/dist/commands/form/pull.js +13 -32
  19. package/dist/commands/form/push.d.ts +1 -1
  20. package/dist/commands/form/push.js +6 -14
  21. package/dist/commands/init.d.ts +2 -0
  22. package/dist/commands/init.js +65 -33
  23. package/dist/commands/page/list.d.ts +9 -0
  24. package/dist/commands/page/list.js +26 -0
  25. package/dist/commands/page/pull.d.ts +13 -0
  26. package/dist/commands/page/pull.js +65 -0
  27. package/dist/commands/page/push.d.ts +16 -0
  28. package/dist/commands/page/push.js +145 -0
  29. package/dist/commands/plugin/push.d.ts +1 -1
  30. package/dist/commands/plugin/push.js +2 -14
  31. package/dist/commands/project/config.d.ts +1 -0
  32. package/dist/commands/project/config.js +32 -0
  33. package/dist/commands/project/push.d.ts +5 -0
  34. package/dist/commands/project/push.js +21 -11
  35. package/dist/commands/project/rotate.d.ts +7 -0
  36. package/dist/commands/project/rotate.js +20 -0
  37. package/dist/commands/seo/pull.d.ts +1 -3
  38. package/dist/commands/seo/pull.js +15 -43
  39. package/dist/commands/seo/push.d.ts +1 -1
  40. package/dist/commands/seo/push.js +4 -17
  41. package/dist/commands/snippet/publish.js +2 -2
  42. package/dist/commands/snippet/pull.d.ts +1 -4
  43. package/dist/commands/snippet/pull.js +12 -60
  44. package/dist/commands/snippet/push.d.ts +1 -1
  45. package/dist/commands/snippet/push.js +4 -13
  46. package/dist/commands/status.d.ts +4 -0
  47. package/dist/commands/status.js +25 -4
  48. package/dist/config/project-config.manager.js +2 -2
  49. package/dist/lib/api-client.d.ts +2 -1
  50. package/dist/lib/api-client.js +10 -3
  51. package/dist/lib/base.d.ts +12 -0
  52. package/dist/lib/base.js +100 -9
  53. package/dist/lib/bootstrap-full-install.d.ts +10 -0
  54. package/dist/lib/bootstrap-full-install.js +66 -0
  55. package/dist/lib/browser-launch.d.ts +8 -0
  56. package/dist/lib/browser-launch.js +20 -0
  57. package/dist/lib/find-orphaned-files.d.ts +7 -0
  58. package/dist/lib/find-orphaned-files.js +29 -0
  59. package/dist/lib/github-release.d.ts +11 -0
  60. package/dist/lib/github-release.js +37 -0
  61. package/dist/lib/interactive.d.ts +1 -0
  62. package/dist/lib/interactive.js +7 -0
  63. package/dist/lib/load-files.d.ts +5 -0
  64. package/dist/lib/load-files.js +31 -0
  65. package/dist/lib/plugin-detection.d.ts +8 -0
  66. package/dist/lib/plugin-detection.js +11 -0
  67. package/dist/lib/push-command.d.ts +7 -0
  68. package/dist/lib/push-command.js +40 -2
  69. package/dist/lib/rotate-app-password.d.ts +6 -0
  70. package/dist/lib/rotate-app-password.js +33 -0
  71. package/dist/lib/temp-admin.d.ts +19 -0
  72. package/dist/lib/temp-admin.js +46 -0
  73. package/dist/lib/wp-client.d.ts +10 -4
  74. package/dist/lib/wp-client.js +29 -12
  75. package/dist/types/project-config.generated.d.ts +4 -0
  76. package/dist/utils/composer.d.ts +3 -0
  77. package/dist/utils/page-format.d.ts +7 -0
  78. package/dist/utils/page-format.js +78 -0
  79. package/dist/utils/seo-format.d.ts +1 -0
  80. package/dist/utils/seo-format.js +5 -0
  81. package/dist/utils/snippet-format.d.ts +2 -0
  82. package/dist/utils/snippet-format.js +27 -1
  83. package/dist/utils/to-slug.d.ts +1 -0
  84. package/dist/utils/to-slug.js +7 -0
  85. package/oclif.manifest.json +688 -29
  86. package/package.json +16 -9
  87. package/schemas/project-config.schema.json +5 -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 { Command } from '@oclif/core';
2
+ export default class Publish extends Command {
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
+ run(): Promise<void>;
9
+ }
@@ -0,0 +1,60 @@
1
+ import { Args, Command } from '@oclif/core';
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
+ import { ApiClient } from '../../lib/api-client.js';
6
+ import { loadFiles } from '../../lib/load-files.js';
7
+ import { readLocalConfig } from '../../utils/loopress-config.js';
8
+ // Publishes to the Loopress api (not a WordPress site), so this does not extend
9
+ // `LoopressCommand`/`PushCommand`: those force an environment to be resolved, but publishing
10
+ // for cross-project sharing doesn't need one, same reasoning as `snippet publish`. An API route
11
+ // is a special case of the same publish/deploy rules as snippets and ACF (see
12
+ // obsidian/Product/API and Console Backlog.md, US-18): a `push` (direct to WordPress, this
13
+ // project's own site) and a `publish` (to the cloud, deployable to other projects) are two
14
+ // separate, coexisting paths.
15
+ export default class Publish extends Command {
16
+ static args = {
17
+ path: Args.string({ description: 'Path to api directory (overrides project config)' }),
18
+ };
19
+ static description = 'Publish custom API routes to your Loopress account so they can be deployed to other projects. Does not touch any WordPress site.';
20
+ static examples = ['$ lps api publish', '$ lps api publish --path ./api'];
21
+ async run() {
22
+ 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
+ }
39
+ const path = args.path ?? join(localConfig.rootDir ?? '.', localConfig.apiDir ?? 'api');
40
+ this.log(`Publishing API routes from ${path}`);
41
+ const files = await loadFiles(path, {
42
+ extension: '.php',
43
+ onSkip: (message) => this.warn(message),
44
+ parse: (raw, filePath) => ({ code: raw, filename: basename(filePath, '.php') }),
45
+ });
46
+ const api = new ApiClient(token);
47
+ try {
48
+ await api.post(`projects/${project.apiProjectId}/api-routes/publish/upsert`, {
49
+ routes: files,
50
+ });
51
+ await api.post(`projects/${project.apiProjectId}/api-routes/publish/prune`, {
52
+ filenames: files.map((file) => file.filename),
53
+ });
54
+ }
55
+ catch (error) {
56
+ this.error(error.message);
57
+ }
58
+ this.log(`Published ${files.length} route${files.length === 1 ? '' : 's'} to your Loopress account.`);
59
+ }
60
+ }
@@ -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,10 +6,11 @@ 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;
15
+ private syncApiRoutes;
15
16
  }
@@ -1,8 +1,18 @@
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 { authManager } from '../../config/auth.manager.js';
5
+ import { ApiClient } from '../../lib/api-client.js';
6
+ import { loadFiles as loadDirectoryFiles } from '../../lib/load-files.js';
5
7
  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-]+$/;
6
16
  export default class Push extends PushCommand {
7
17
  static args = {
8
18
  path: Args.string({ description: 'Path to api directory (overrides project config)' }),
@@ -11,8 +21,8 @@ export default class Push extends PushCommand {
11
21
  static examples = ['$ lps api push', '$ lps api push --path ./api'];
12
22
  static flags = {
13
23
  ...PushCommand.dryRunFlag,
24
+ ...PushCommand.yesFlag,
14
25
  };
15
- failedCount = 0;
16
26
  async run() {
17
27
  const { args } = await this.parse(Push);
18
28
  const { url } = this.siteConfig;
@@ -31,28 +41,21 @@ export default class Push extends PushCommand {
31
41
  if (this.dryRun)
32
42
  return;
33
43
  await this.recordSuccess();
44
+ await this.syncApiRoutes(files.map((file) => file.filename));
34
45
  this.log('All API routes pushed.');
35
46
  }
36
47
  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;
48
+ return loadDirectoryFiles(path, {
49
+ extension: '.php',
50
+ onSkip: (message) => this.warn(message),
51
+ parse: (raw, filePath) => ({ content: raw, filename: basename(filePath, '.php') }),
52
+ });
54
53
  }
55
54
  async pushFile(file, task) {
55
+ 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")`;
57
+ this.reportTaskFailure(message, new Error(message), task);
58
+ }
56
59
  if (this.dryRun) {
57
60
  if (task)
58
61
  task.output = `[dry-run] Would push: ${file.filename}`;
@@ -64,13 +67,23 @@ export default class Push extends PushCommand {
64
67
  task.output = `Pushed: ${file.filename}`;
65
68
  }
66
69
  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;
70
+ this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
71
+ }
72
+ }
73
+ // Best-effort report of the current file list to the Loopress cloud, purely for console
74
+ // visibility (see obsidian/Product/API and Console Backlog.md, US-18): the routes are already
75
+ // live on WordPress by this point, this can never block or fail the push itself, same
76
+ // reasoning as `recordDeployment` in PushCommand.
77
+ async syncApiRoutes(filenames) {
78
+ const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token;
79
+ const environmentId = this.siteConfig.apiEnvironmentId;
80
+ if (!token || !environmentId)
81
+ return;
82
+ try {
83
+ await new ApiClient(token).put('api-routes', { environmentId, filenames });
84
+ }
85
+ catch {
86
+ // non-blocking: reporting the route list must never interrupt the push flow
74
87
  }
75
88
  }
76
89
  }
@@ -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;