@loopress/cli 0.22.0 → 0.24.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 (117) hide show
  1. package/README.md +794 -85
  2. package/dist/commands/acf/diff.d.ts +2 -0
  3. package/dist/commands/acf/diff.js +5 -0
  4. package/dist/commands/api/diff.d.ts +2 -0
  5. package/dist/commands/api/diff.js +5 -0
  6. package/dist/commands/api/list.d.ts +2 -12
  7. package/dist/commands/api/list.js +2 -20
  8. package/dist/commands/api/pull.d.ts +2 -20
  9. package/dist/commands/api/pull.js +2 -53
  10. package/dist/commands/api/push.d.ts +2 -22
  11. package/dist/commands/api/push.js +2 -116
  12. package/dist/commands/app/list.d.ts +17 -0
  13. package/dist/commands/app/list.js +35 -0
  14. package/dist/commands/{page → app}/pull.d.ts +7 -5
  15. package/dist/commands/app/pull.js +94 -0
  16. package/dist/commands/app/push.d.ts +28 -0
  17. package/dist/commands/app/push.js +130 -0
  18. package/dist/commands/app/remove.d.ts +20 -0
  19. package/dist/commands/app/remove.js +42 -0
  20. package/dist/commands/composer/pull.js +6 -16
  21. package/dist/commands/composer/push.d.ts +1 -0
  22. package/dist/commands/composer/push.js +43 -19
  23. package/dist/commands/dev.js +1 -1
  24. package/dist/commands/diff.d.ts +13 -0
  25. package/dist/commands/diff.js +42 -0
  26. package/dist/commands/form/diff.d.ts +2 -0
  27. package/dist/commands/form/diff.js +5 -0
  28. package/dist/commands/hook/diff.d.ts +2 -0
  29. package/dist/commands/hook/diff.js +5 -0
  30. package/dist/commands/hook/list.d.ts +2 -0
  31. package/dist/commands/hook/list.js +2 -0
  32. package/dist/commands/hook/pull.d.ts +2 -0
  33. package/dist/commands/hook/pull.js +2 -0
  34. package/dist/commands/hook/push.d.ts +2 -0
  35. package/dist/commands/hook/push.js +2 -0
  36. package/dist/commands/init.js +2 -1
  37. package/dist/commands/option/add.d.ts +15 -0
  38. package/dist/commands/option/add.js +47 -0
  39. package/dist/commands/option/diff.d.ts +2 -0
  40. package/dist/commands/option/diff.js +5 -0
  41. package/dist/commands/{page → option}/list.d.ts +5 -1
  42. package/dist/commands/option/list.js +151 -0
  43. package/dist/commands/option/pull.d.ts +16 -0
  44. package/dist/commands/option/pull.js +75 -0
  45. package/dist/commands/{page → option}/push.d.ts +3 -8
  46. package/dist/commands/option/push.js +66 -0
  47. package/dist/commands/option/remove.d.ts +22 -0
  48. package/dist/commands/option/remove.js +69 -0
  49. package/dist/commands/plugin/add.d.ts +1 -0
  50. package/dist/commands/plugin/add.js +18 -8
  51. package/dist/commands/plugin/audit.d.ts +30 -0
  52. package/dist/commands/plugin/audit.js +138 -0
  53. package/dist/commands/plugin/pull.d.ts +1 -2
  54. package/dist/commands/plugin/pull.js +11 -11
  55. package/dist/commands/plugin/push.d.ts +13 -8
  56. package/dist/commands/plugin/push.js +161 -67
  57. package/dist/commands/plugin/status.d.ts +22 -0
  58. package/dist/commands/plugin/status.js +65 -0
  59. package/dist/commands/pull.js +3 -2
  60. package/dist/commands/push.js +4 -3
  61. package/dist/commands/seo/diff.d.ts +2 -0
  62. package/dist/commands/seo/diff.js +5 -0
  63. package/dist/commands/snippet/diff.d.ts +2 -0
  64. package/dist/commands/snippet/diff.js +5 -0
  65. package/dist/commands/theme/add.d.ts +13 -0
  66. package/dist/commands/theme/add.js +35 -0
  67. package/dist/commands/theme/pull.d.ts +21 -0
  68. package/dist/commands/theme/pull.js +37 -0
  69. package/dist/commands/theme/push.d.ts +22 -0
  70. package/dist/commands/theme/push.js +123 -0
  71. package/dist/commands/theme/status.d.ts +20 -0
  72. package/dist/commands/theme/status.js +51 -0
  73. package/dist/lib/app-manifest.d.ts +36 -0
  74. package/dist/lib/app-manifest.js +162 -0
  75. package/dist/lib/base.d.ts +3 -1
  76. package/dist/lib/base.js +14 -19
  77. package/dist/lib/dev-watch.d.ts +1 -1
  78. package/dist/lib/dev-watch.js +1 -2
  79. package/dist/lib/diff-command.d.ts +47 -0
  80. package/dist/lib/diff-command.js +180 -0
  81. package/dist/lib/diff-state.d.ts +22 -0
  82. package/dist/lib/diff-state.js +88 -0
  83. package/dist/lib/find-orphaned-files.js +1 -1
  84. package/dist/lib/interactive.d.ts +1 -0
  85. package/dist/lib/interactive.js +10 -0
  86. package/dist/lib/php-files-command.d.ts +54 -0
  87. package/dist/lib/php-files-command.js +216 -0
  88. package/dist/lib/put-or-create.js +1 -1
  89. package/dist/lib/readdir-tolerant.d.ts +1 -0
  90. package/dist/lib/readdir-tolerant.js +3 -1
  91. package/dist/lib/resource-state.d.ts +14 -0
  92. package/dist/lib/resource-state.js +361 -0
  93. package/dist/lib/validate-local.js +15 -7
  94. package/dist/lib/wp-client.d.ts +5 -0
  95. package/dist/lib/wp-client.js +56 -0
  96. package/dist/types/plugin.d.ts +1 -0
  97. package/dist/types/project-config.generated.d.ts +19 -5
  98. package/dist/utils/option-format.d.ts +31 -0
  99. package/dist/utils/option-format.js +51 -0
  100. package/dist/utils/plugin-sync.d.ts +26 -0
  101. package/dist/utils/plugin-sync.js +31 -0
  102. package/dist/utils/plugins.d.ts +15 -2
  103. package/dist/utils/plugins.js +57 -12
  104. package/dist/utils/resource-dirs.d.ts +13 -0
  105. package/dist/utils/resource-dirs.js +34 -0
  106. package/dist/utils/themes.d.ts +30 -0
  107. package/dist/utils/themes.js +29 -0
  108. package/dist/utils/version.d.ts +2 -0
  109. package/dist/utils/version.js +21 -0
  110. package/oclif.manifest.json +1897 -244
  111. package/package.json +26 -19
  112. package/schemas/project-config.schema.json +32 -9
  113. package/dist/commands/page/list.js +0 -19
  114. package/dist/commands/page/pull.js +0 -57
  115. package/dist/commands/page/push.js +0 -132
  116. package/dist/utils/page-format.d.ts +0 -7
  117. package/dist/utils/page-format.js +0 -78
@@ -0,0 +1,42 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Args } from '@oclif/core';
3
+ import { LoopressCommand } from '../../lib/base.js';
4
+ import { guardProductionPush } from '../../lib/guard-production-push.js';
5
+ import { isInteractive } from '../../lib/interactive.js';
6
+ export default class Remove extends LoopressCommand {
7
+ static args = {
8
+ name: Args.string({ description: 'App to remove from WordPress', required: true }),
9
+ };
10
+ static description = 'Remove a single-page app from WordPress: deletes its bundle from wp-content/loopress/apps/ and unregisters the shortcode. Local files are left untouched.';
11
+ static enableJsonFlag = true;
12
+ static examples = ['$ lps app remove search'];
13
+ static flags = {
14
+ ...LoopressCommand.dryRunFlag,
15
+ ...LoopressCommand.yesFlag,
16
+ };
17
+ async run() {
18
+ const { args } = await this.parse(Remove);
19
+ const { name } = args;
20
+ const { url } = this.siteConfig;
21
+ if (this.dryRun) {
22
+ this.log(`[dry-run] Would delete app "${name}" from ${url}`);
23
+ return { deleted: false, name, status: 'dry-run' };
24
+ }
25
+ await guardProductionPush({
26
+ dryRun: this.dryRun,
27
+ error: (message) => this.error(message),
28
+ siteConfig: this.siteConfig,
29
+ yes: this.yes,
30
+ });
31
+ if (!this.yes && isInteractive()) {
32
+ const isProceed = await confirm({ default: false, message: `Delete app "${name}" from ${url}?` });
33
+ if (!isProceed) {
34
+ this.log('Aborted.');
35
+ return { deleted: false, name, status: 'aborted' };
36
+ }
37
+ }
38
+ await this.wp.delete(`loopress/v1/apps/${name}`);
39
+ this.log(`Removed app "${name}" from ${url}`);
40
+ return { deleted: true, name, status: 'success' };
41
+ }
42
+ }
@@ -1,23 +1,13 @@
1
1
  import { writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { LoopressCommand } from '../../lib/base.js';
4
- import { isNotFoundError } from '../../lib/wp-client.js';
5
- // Narrower than isNotFoundError(): a bare 404 also covers the route being absent (plugin not
6
- // installed, or an edition/version predating Composer support), which must still surface the
7
- // normal "is the plugin installed?" guidance rather than being read as "no lock yet".
4
+ import { isApplicative404 } from '../../lib/wp-client.js';
5
+ // A site that never had Composer dependencies pushed has no composer.lock yet: the Loopress
6
+ // controller answers that specific 404 with `{"error": "composer.lock not found"}`. Any other
7
+ // 404 (route absent because the plugin isn't installed or predates Composer support) must
8
+ // still surface the normal "is the plugin installed?" guidance instead of being swallowed.
8
9
  function isMissingComposerLock(error) {
9
- if (!isNotFoundError(error))
10
- return false;
11
- const body = error.cause?.response?.body;
12
- if (!body)
13
- return false;
14
- try {
15
- const parsed = JSON.parse(body);
16
- return parsed.error === 'composer.lock not found';
17
- }
18
- catch {
19
- return false;
20
- }
10
+ return isApplicative404(error, 'composer.lock not found');
21
11
  }
22
12
  export default class ComposerPull extends LoopressCommand {
23
13
  static description = 'Pull composer.json and composer.lock from WordPress';
@@ -9,6 +9,7 @@ export default class ComposerPush extends PushCommand {
9
9
  static enableJsonFlag: boolean;
10
10
  static examples: string[];
11
11
  static flags: {
12
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
13
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
14
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
15
  };
@@ -1,53 +1,77 @@
1
+ import { Flags } from '@oclif/core';
1
2
  import { existsSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { PushCommand } from '../../lib/push-command.js';
5
6
  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;
7
+ import { parseCollisions, SYNC_TIMEOUT_MS } from '../../utils/plugin-sync.js';
8
+ // Split a composer.json `require` map into the three intent namespaces the sync endpoint
9
+ // understands. `composer/installers` is owned by the server scaffold, never sent.
10
+ function toIntent(require) {
11
+ const intent = { libraries: {}, plugins: {}, themes: {} };
12
+ for (const [name, constraint] of Object.entries(require)) {
13
+ if (name === 'composer/installers')
14
+ continue;
15
+ if (name.startsWith('wpackagist-plugin/')) {
16
+ intent.plugins[name.slice('wpackagist-plugin/'.length)] = constraint;
17
+ }
18
+ else if (name.startsWith('wpackagist-theme/')) {
19
+ intent.themes[name.slice('wpackagist-theme/'.length)] = constraint;
20
+ }
21
+ else {
22
+ intent.libraries[name] = constraint;
23
+ }
24
+ }
25
+ return intent;
26
+ }
8
27
  export default class ComposerPush extends PushCommand {
9
- static description = 'Push composer.json and composer.lock to WordPress and run composer install';
28
+ static description = 'Push composer.json (and composer.lock, if present) to WordPress and run Composer';
10
29
  static enableJsonFlag = true;
11
30
  static examples = ['$ lps composer push', '$ lps composer push --dry-run'];
12
31
  static flags = {
13
32
  ...PushCommand.dryRunFlag,
14
33
  ...PushCommand.yesFlag,
34
+ force: Flags.boolean({ default: false, description: 'Allow downgrades and take over plugins/themes installed outside Loopress' }),
15
35
  };
16
36
  async run() {
17
37
  const { url } = this.siteConfig;
38
+ const { flags } = await this.parse(ComposerPush);
39
+ const { force } = flags;
18
40
  const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
19
41
  const composerLockPath = join(process.cwd(), this.rootDir, 'composer.lock');
20
42
  if (!existsSync(composerJsonPath)) {
21
- this.error(`No composer.json found at ${composerJsonPath}`);
43
+ this.error(`No composer.json found at ${composerJsonPath}. Run \`lps composer init\` first.`);
22
44
  }
23
- const composerJsonRaw = await readFile(composerJsonPath, 'utf8');
24
- const parsed = JSON.parse(composerJsonRaw);
25
- const packageCount = Object.keys(parsed.require ?? {}).length;
45
+ const parsed = JSON.parse(await readFile(composerJsonPath, 'utf8'));
46
+ const require = parsed.require ?? {};
47
+ const packageCount = Object.keys(require).length;
48
+ const intent = toIntent(require);
26
49
  const hasLock = existsSync(composerLockPath);
27
- const composerLockRaw = hasLock ? await readFile(composerLockPath, 'utf8') : null;
50
+ const lock = hasLock ? await readFile(composerLockPath, 'utf8') : null;
28
51
  this.log(`Pushing composer.json (${packageCount} ${packageCount === 1 ? 'package' : 'packages'}) to ${url}`);
29
- if (composerLockRaw) {
52
+ if (lock)
30
53
  this.log(' + composer.lock included (reproducible install)');
31
- }
32
- else {
54
+ else
33
55
  this.warn('No composer.lock found. The server will resolve versions freely.');
34
- }
35
56
  if (this.dryRun)
36
57
  return { hasLock, packageCount, status: 'dry-run' };
37
- this.log('Running composer install on the server, this can take a few minutes...');
58
+ this.log('Running Composer on the server, this can take a few minutes...');
59
+ let response;
38
60
  try {
39
- await this.wp.post('loopress/v1/composer/sync', {
40
- composerJson: composerJsonRaw,
41
- composerLock: composerLockRaw,
42
- }, { timeoutMs: COMPOSER_SYNC_TIMEOUT_MS });
61
+ response = await this.wp.post('loopress/v1/composer/sync', { force, intent, lock }, { timeoutMs: SYNC_TIMEOUT_MS });
43
62
  }
44
63
  catch (error) {
64
+ if (parseCollisions(error)) {
65
+ this.error('Plugins or themes are installed outside Loopress. Re-run with --force to take them over.');
66
+ }
45
67
  if (isTimeoutError(error)) {
46
- this.error(`${error.message} The composer install may still be running on the server.`);
68
+ this.error(`${error.message} The Composer run may still be in progress on the server.`);
47
69
  }
48
70
  throw error;
49
71
  }
50
- this.log('composer install completed on the server.');
72
+ if (response.output.trim())
73
+ this.log(response.output.trim());
74
+ this.log('Composer run completed on the server.');
51
75
  await this.recordSuccess();
52
76
  return { hasLock, packageCount, status: 'success' };
53
77
  }
@@ -9,7 +9,7 @@ const DEBOUNCE_MS = 400;
9
9
  const IGNORED = /(?:(^|[/\\])(\.git|node_modules|\.DS_Store)(?:[/\\]|$))|(?:\.swp$)/;
10
10
  export default class Dev extends Command {
11
11
  static description = 'Watch project files and push changes to the local WordPress instance as they happen. Always targets the "local" environment, run `lps snippet push` etc. directly for any other environment.';
12
- static examples = ['$ lps dev', '$ lps dev --only=snippets,pages', '$ lps dev --skip=plugins'];
12
+ static examples = ['$ lps dev', '$ lps dev --only=snippets,api', '$ lps dev --skip=plugins'];
13
13
  static flags = {
14
14
  only: Flags.string({ description: `Only watch these resource types (comma-separated): ${RESOURCE_TYPES.join(', ')}` }),
15
15
  skip: Flags.string({ description: `Skip these resource types (comma-separated): ${RESOURCE_TYPES.join(', ')}` }),
@@ -0,0 +1,13 @@
1
+ import { DiffCommand, type DiffJson } from '../lib/diff-command.js';
2
+ export default class Diff extends DiffCommand {
3
+ static description: string;
4
+ static enableJsonFlag: boolean;
5
+ static examples: string[];
6
+ static flags: {
7
+ only: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ skip: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ against: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ };
11
+ run(): Promise<DiffJson>;
12
+ private selectResources;
13
+ }
@@ -0,0 +1,42 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { DiffCommand } from '../lib/diff-command.js';
3
+ import { RESOURCE_STATE_PROVIDERS } from '../lib/resource-state.js';
4
+ // Everything `lps diff` can compare, in report order. Composer isn't a directory-backed
5
+ // provider (see resource-state.ts) so it's named here explicitly.
6
+ const RESOURCES = [...RESOURCE_STATE_PROVIDERS.map((provider) => provider.resource), 'composer'];
7
+ export default class Diff extends DiffCommand {
8
+ static description = [
9
+ 'Show what differs between your local tracked files and a WordPress environment, or between two environments.',
10
+ 'Covers snippets, forms, ACF, API routes, Hooks, SEO, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
11
+ 'Exit code: 0 in sync, 1 on drift, 2 when a resource could not be compared, so it doubles as a CI drift gate.',
12
+ ].join(' ');
13
+ static enableJsonFlag = true;
14
+ static examples = [
15
+ '$ lps diff',
16
+ '$ lps diff --env staging',
17
+ '$ lps diff --env staging --against production',
18
+ '$ lps diff --only snippet --only acf',
19
+ '$ lps diff --skip composer',
20
+ ];
21
+ static flags = {
22
+ ...DiffCommand.againstFlag,
23
+ only: Flags.string({ description: 'Only compare these resources', multiple: true, options: [...RESOURCES] }),
24
+ skip: Flags.string({ description: 'Compare every resource except these', multiple: true, options: [...RESOURCES] }),
25
+ };
26
+ async run() {
27
+ const { flags } = await this.parse(Diff);
28
+ const sides = this.resolveSides(flags.against);
29
+ const selected = this.selectResources(flags.only, flags.skip);
30
+ const targets = [
31
+ ...RESOURCE_STATE_PROVIDERS.filter((provider) => selected.has(provider.resource)).map((provider) => this.providerTarget(provider, sides)),
32
+ ...(selected.has('composer') ? [this.composerTarget(sides)] : []),
33
+ ];
34
+ return this.report(targets, sides);
35
+ }
36
+ // `--only` narrows the default set, `--skip` then removes from whatever that set is, same
37
+ // precedence a combined `--only=a,b --skip=b` reads as (mirrors dev-watch's resolveResourceTypes).
38
+ selectResources(only, skip) {
39
+ const base = only && only.length > 0 ? only : RESOURCES;
40
+ return new Set(base.filter((resource) => !(skip ?? []).includes(resource)));
41
+ }
42
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/diff-command.js").DiffCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceDiffCommand } from '../../lib/diff-command.js';
2
+ export default resourceDiffCommand('form', {
3
+ description: 'Show what differs in forms between your local files and a WordPress environment, or between two environments',
4
+ pathNoun: 'forms directory',
5
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/diff-command.js").DiffCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceDiffCommand } from '../../lib/diff-command.js';
2
+ export default resourceDiffCommand('hook', {
3
+ description: 'Show what differs in hook files between your local files and a WordPress environment, or between two environments',
4
+ pathNoun: 'hooks directory',
5
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").ListFilesCommand>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { HOOK_FILES_RESOURCE, resourceListCommand } from '../../lib/php-files-command.js';
2
+ export default resourceListCommand(HOOK_FILES_RESOURCE);
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").PullFilesCommand>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { HOOK_FILES_RESOURCE, resourcePullCommand } from '../../lib/php-files-command.js';
2
+ export default resourcePullCommand(HOOK_FILES_RESOURCE);
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").PushFilesCommand>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { HOOK_FILES_RESOURCE, resourcePushCommand } from '../../lib/php-files-command.js';
2
+ export default resourcePushCommand(HOOK_FILES_RESOURCE);
@@ -16,8 +16,9 @@ const FEATURES = [
16
16
  { dir: 'acf', key: 'acfDir', label: 'ACF' },
17
17
  { dir: 'seo', key: 'seoDir', label: 'SEO' },
18
18
  { dir: 'forms', key: 'formDir', label: 'Forms' },
19
- { dir: 'pages', key: 'pageDir', label: 'Pages' },
20
19
  { dir: 'api', key: 'apiDir', label: 'Custom API routes' },
20
+ { dir: 'hooks', key: 'hooksDir', label: 'Hooks (actions, filters, cron)' },
21
+ { dir: 'apps', key: 'appsDir', label: 'Single-page apps' },
21
22
  ];
22
23
  export default class Init extends Command {
23
24
  static description = 'Initialize a loopress.json config file in the current directory';
@@ -0,0 +1,15 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { type LocalOption } from '../../utils/option-format.js';
3
+ export default class Add extends LoopressCommand {
4
+ static args: {
5
+ name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static enableJsonFlag: boolean;
9
+ static examples: string[];
10
+ static flags: {
11
+ path: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ };
14
+ run(): Promise<LocalOption>;
15
+ }
@@ -0,0 +1,47 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { LoopressCommand } from '../../lib/base.js';
5
+ import { isNotFoundError } from '../../lib/wp-client.js';
6
+ import { defaultReadonlyFor, isReservedOptionName, optionEndpoint, optionFileName, } from '../../utils/option-format.js';
7
+ export default class Add extends LoopressCommand {
8
+ static args = {
9
+ name: Args.string({ description: 'Option name to start tracking (see `lps option list`)', required: true }),
10
+ };
11
+ static description = 'Fetch a WordPress option by name and start tracking it locally. Run `lps option list` first to find the name: ' +
12
+ 'this copies a raw value across environments, verify it does not embed post/user IDs before pushing it elsewhere.';
13
+ static enableJsonFlag = true;
14
+ static examples = ['$ lps option add wpseo_titles', '$ lps option add siteurl'];
15
+ static flags = {
16
+ ...LoopressCommand.dryRunFlag,
17
+ path: Flags.string({ description: 'Path to options directory (overrides project config)' }),
18
+ };
19
+ async run() {
20
+ const { args, flags } = await this.parse(Add);
21
+ const { name } = args;
22
+ const dir = this.resolveOptionsPath(flags.path);
23
+ if (isReservedOptionName(name)) {
24
+ this.error(`"${name}" is managed by \`lps plugin\`/\`lps theme\`, not \`lps option\`.`);
25
+ }
26
+ let remote;
27
+ try {
28
+ remote = await this.wp.get(optionEndpoint(name));
29
+ }
30
+ catch (error) {
31
+ if (isNotFoundError(error)) {
32
+ this.error(`Option "${name}" was not found on ${this.siteConfig.url}. Run \`lps option list\` to see available names.`);
33
+ }
34
+ throw error;
35
+ }
36
+ const local = { ...remote, readonly: defaultReadonlyFor(name) };
37
+ const file = join(dir, optionFileName(name));
38
+ if (this.dryRun) {
39
+ this.log(`[dry-run] Would track "${name}" at ${file}`);
40
+ return local;
41
+ }
42
+ await mkdir(dir, { recursive: true });
43
+ await writeFile(file, JSON.stringify(local, null, 2) + '\n');
44
+ this.log(`Tracking "${name}"${local.readonly ? ' (readonly: push will skip it, see the file to override)' : ''} at ${file}`);
45
+ return local;
46
+ }
47
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/diff-command.js").DiffCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceDiffCommand } from '../../lib/diff-command.js';
2
+ export default resourceDiffCommand('option', {
3
+ description: 'Show what differs, for locally tracked options, between your local files and a WordPress environment, or between two environments',
4
+ pathNoun: 'options directory',
5
+ });
@@ -1,7 +1,11 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ import { type ListedOption } from '../../utils/option-format.js';
2
3
  export default class List extends LoopressCommand {
3
4
  static description: string;
4
5
  static enableJsonFlag: boolean;
5
6
  static examples: string[];
6
- run(): Promise<Array<Record<string, unknown>>>;
7
+ static flags: {
8
+ 'no-core': import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ };
10
+ run(): Promise<ListedOption[]>;
7
11
  }
@@ -0,0 +1,151 @@
1
+ import { Flags, ux } from '@oclif/core';
2
+ import ttyTable from 'tty-table';
3
+ import { LoopressCommand } from '../../lib/base.js';
4
+ import { OPTIONS_ENDPOINT } from '../../utils/option-format.js';
5
+ import { pluralize } from '../../utils/pluralize.js';
6
+ // Same green/yellow pairing drives both SOURCE? and PLUGIN, so a row's whole "where did this come
7
+ // from" story reads as one color at a glance instead of requiring the "?" to be spotted in text.
8
+ function confidenceColor(option) {
9
+ if (!option.guess)
10
+ return undefined;
11
+ return option.confirmed ? 'green' : 'yellow';
12
+ }
13
+ function colorize(color, text) {
14
+ return color ? ux.colorize(color, text) : text;
15
+ }
16
+ // SOURCE? is a best-effort guess, kept visually uncertain with a trailing "?" unless `confirmed`
17
+ // (the guessed plugin's own source was found to reference the name), which drops the "?".
18
+ function sourceCell(option) {
19
+ if (!option.guess)
20
+ return '';
21
+ return colorize(confidenceColor(option), option.confirmed ? option.guess : `${option.guess}?`);
22
+ }
23
+ function pluginCell(option) {
24
+ return option.pluginName ? colorize(confidenceColor(option), option.pluginName) : '';
25
+ }
26
+ function coreCell(option) {
27
+ return option.core ? ux.colorize('cyan', 'yes') : '';
28
+ }
29
+ // Spells out what the colors mean once, up front, rather than leaving green/yellow (and cyan when
30
+ // CORE is shown) to be decoded from the table alone.
31
+ function renderLegend(includeCore) {
32
+ const items = [`${ux.colorize('green', '■')} confirmed`, `${ux.colorize('yellow', '■')} guessed, unconfirmed (?)`];
33
+ if (includeCore)
34
+ items.push(`${ux.colorize('cyan', '■')} WordPress core`);
35
+ return items.join(' '.repeat(3));
36
+ }
37
+ // AUTOLOAD/CORE deliberately get no `width` below, leaving them 'auto' (content-sized): tty-table
38
+ // has a real bug in its fixed-width path (confirmed against its own source, format.js's
39
+ // getColumnWidths) where `result = column.width; result = result + config.GUTTER` string-
40
+ // concatenates a width like "11" with the gutter number instead of adding them ("11" + 1 ===
41
+ // "111"), massively overshooting. 'auto' does the arithmetic correctly and is exactly what these
42
+ // two columns need anyway: their own header ("AUTOLOAD"/"CORE") is the longest thing they ever
43
+ // have to display, at 11 and 7 chars respectively (text + 2 padding + 1 gutter).
44
+ const AUTO_COLUMN_CHARS = { autoload: 11, core: 7 };
45
+ // Relative weights, not literal percentages (normalized below): NAME varies most (plugin slugs
46
+ // and cache keys can run long) so it gets the largest share of whatever's left once AUTOLOAD/CORE
47
+ // take their fixed chars.
48
+ const COLUMN_WEIGHT = { name: 40, plugin: 24, source: 28 };
49
+ // Each percentage column also costs 2 chars of padding + 1 gutter beyond its own percentage share
50
+ // (tty-table adds those on top, not out of it); NAME/SOURCE?/PLUGIN are always all three present.
51
+ const PERCENTAGE_COLUMN_OVERHEAD = 3 * 3;
52
+ // Columns stay content-sized by default (tty-table only shrinks to fit, never grows), which left
53
+ // the table narrow with a lot of unused terminal width. Assigning NAME/SOURCE?/PLUGIN an explicit
54
+ // percentage of the terminal forces them to fill it instead. The percentage has to be computed
55
+ // against the actual available width, not a flat constant: a flat "88%" left enough room at a
56
+ // wide terminal but, combined with AUTOLOAD/CORE's own fixed chars and the padding/gutter overhead
57
+ // above, overran an ordinary ~100-column one, and tty-table's shrink-to-fit response shrank
58
+ // AUTOLOAD/CORE below their own header text, truncating "AUTOLOAD" to "AUTOL…" (found testing at
59
+ // exactly that width).
60
+ function fillPercent(width, includeCore) {
61
+ const reserved = AUTO_COLUMN_CHARS.autoload + (includeCore ? AUTO_COLUMN_CHARS.core : 0) + PERCENTAGE_COLUMN_OVERHEAD + 4; // +4: slim right margin
62
+ return Math.max(50, ((width - reserved) / width) * 100);
63
+ }
64
+ function columnWidth(weight, width, includeCore) {
65
+ const total = COLUMN_WEIGHT.name + COLUMN_WEIGHT.source + COLUMN_WEIGHT.plugin;
66
+ return `${Math.round((weight / total) * fillPercent(width, includeCore))}%`;
67
+ }
68
+ // tty-table sizes columns against process.stdout.columns when present (a real terminal, correctly
69
+ // left alone: a genuinely narrow window should wrap). Piped/redirected output and this process
70
+ // itself (no TTY) has neither that nor $COLUMNS, so the library falls back to a hardcoded 80 and
71
+ // squeezes every cell into broken multi-line wrapping regardless of any width option passed in,
72
+ // confirmed against its own source (getAvailableWidth() in format.js only reads config.width at
73
+ // all when one of the two is set). A generous $COLUMNS makes that fallback path wide instead.
74
+ const NON_TTY_FALLBACK_COLUMNS = 300;
75
+ // Bordered table via tty-table (an oclif-recommended table library, see
76
+ // https://oclif.io/docs/user_experience). CORE is a fact (matched against WordPress's own
77
+ // install-time defaults), hidden entirely under --no-core rather than left as an all-blank
78
+ // column. See confidenceColor() above for SOURCE?/PLUGIN's shared sûr/pas sûr color coding.
79
+ function renderTable(rows, { includeCore }) {
80
+ const previousColumns = process.env.COLUMNS;
81
+ if (!process.stdout.columns)
82
+ process.env.COLUMNS = String(NON_TTY_FALLBACK_COLUMNS);
83
+ // Mirrors tty-table's own getAvailableWidth(): its column-width percentages are computed against
84
+ // this same number, so fillPercent() above needs it too to reserve the right amount for AUTOLOAD/CORE.
85
+ const width = (process.stdout.columns || Number(process.env.COLUMNS) || NON_TTY_FALLBACK_COLUMNS) - 2;
86
+ const header = [
87
+ { align: 'left', headerAlign: 'left', value: 'NAME', width: columnWidth(COLUMN_WEIGHT.name, width, includeCore) },
88
+ { align: 'left', headerAlign: 'left', value: 'AUTOLOAD' },
89
+ ];
90
+ if (includeCore)
91
+ header.push({ align: 'left', headerAlign: 'left', value: 'CORE' });
92
+ header.push({ align: 'left', headerAlign: 'left', value: 'SOURCE?', width: columnWidth(COLUMN_WEIGHT.source, width, includeCore) }, { align: 'left', headerAlign: 'left', value: 'PLUGIN', width: columnWidth(COLUMN_WEIGHT.plugin, width, includeCore) });
93
+ const body = rows.map((row) => {
94
+ const cells = [row.name, row.autoload];
95
+ if (includeCore)
96
+ cells.push(coreCell(row));
97
+ cells.push(sourceCell(row), pluginCell(row));
98
+ return cells;
99
+ });
100
+ try {
101
+ // truncate (rather than the default wrap) so an oversized cell, like a WPForms cache key
102
+ // seen live running ~140 chars, ellipsizes on one line instead of blowing up the row height.
103
+ return ttyTable(header, body, { borderStyle: 'solid', compact: true, truncate: '…' }).render();
104
+ }
105
+ finally {
106
+ if (previousColumns === undefined)
107
+ delete process.env.COLUMNS;
108
+ else
109
+ process.env.COLUMNS = previousColumns;
110
+ }
111
+ }
112
+ export default class List extends LoopressCommand {
113
+ static description = 'List WordPress option names currently on the site (names and autoload only, never values). ' +
114
+ 'CORE flags a WordPress-native default (certain); SOURCE? is a best-effort guessed plugin slug, found by name or by ' +
115
+ 'scanning active plugins\' own PHP source (uncertain unless confirmed, occasionally wrong or blank, never trust it over CORE); ' +
116
+ 'PLUGIN is that slug\'s own declared display name, a label only, no confidence of its own. ' +
117
+ 'Use this to find the name of the option you want, then `lps option add <name>` to track it.';
118
+ static enableJsonFlag = true;
119
+ static examples = ['$ lps option list', '$ lps option list --no-core'];
120
+ static flags = {
121
+ 'no-core': Flags.boolean({
122
+ description: 'Exclude WordPress-native default options and the CORE column from the output, including --json',
123
+ }),
124
+ };
125
+ async run() {
126
+ const { flags } = await this.parse(List);
127
+ // Every call scans active plugins' own PHP source for names the naming guess missed (not
128
+ // opt-in, roughly 1-3s on a real site once vendor/tests/languages are pruned from the scan;
129
+ // see OptionsService::listOptionNames() server-side), hence the spinner unconditionally.
130
+ ux.action.start('Fetching and verifying options');
131
+ let options;
132
+ try {
133
+ options = await this.wp.get(OPTIONS_ENDPOINT);
134
+ }
135
+ finally {
136
+ ux.action.stop();
137
+ }
138
+ if (flags['no-core'])
139
+ options = options.filter((option) => !option.core);
140
+ if (options.length === 0) {
141
+ this.log('No options found');
142
+ return options;
143
+ }
144
+ const includeCore = !flags['no-core'];
145
+ this.log(`Found ${pluralize(options.length, 'option')}:\n`);
146
+ this.log(renderLegend(includeCore));
147
+ this.log('');
148
+ this.log(renderTable(options, { includeCore }));
149
+ return options;
150
+ }
151
+ }
@@ -0,0 +1,16 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { type LocalOption } from '../../utils/option-format.js';
3
+ export default class Pull extends LoopressCommand {
4
+ static args: {
5
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static enableJsonFlag: boolean;
9
+ static examples: string[];
10
+ static flags: {
11
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ };
14
+ run(): Promise<LocalOption[]>;
15
+ private pullOption;
16
+ }