@loopress/cli 0.28.0 → 0.30.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 (190) hide show
  1. package/README.md +1460 -337
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/acf/pull.d.ts +9 -1
  4. package/dist/commands/acf/pull.js +2 -0
  5. package/dist/commands/acf/push.d.ts +9 -1
  6. package/dist/commands/acf/push.js +3 -1
  7. package/dist/commands/api/publish.js +1 -1
  8. package/dist/commands/app/push.js +1 -1
  9. package/dist/commands/composer/push.js +3 -3
  10. package/dist/commands/cpt/diff.js +5 -0
  11. package/dist/commands/cpt/list.d.ts +2 -0
  12. package/dist/commands/cpt/list.js +4 -0
  13. package/dist/commands/cpt/pull.d.ts +2 -0
  14. package/dist/commands/cpt/pull.js +4 -0
  15. package/dist/commands/cpt/push.d.ts +2 -0
  16. package/dist/commands/cpt/push.js +4 -0
  17. package/dist/commands/cpt/rm.d.ts +2 -0
  18. package/dist/commands/cpt/rm.js +3 -0
  19. package/dist/commands/cpt/rollback.js +5 -0
  20. package/dist/commands/dev.js +14 -4
  21. package/dist/commands/diff.js +1 -1
  22. package/dist/commands/doctor.d.ts +1 -0
  23. package/dist/commands/doctor.js +30 -2
  24. package/dist/commands/form/pull.d.ts +9 -1
  25. package/dist/commands/form/pull.js +3 -1
  26. package/dist/commands/form/push.d.ts +7 -1
  27. package/dist/commands/form/push.js +3 -1
  28. package/dist/commands/init.js +6 -38
  29. package/dist/commands/menu/pull.d.ts +7 -1
  30. package/dist/commands/menu/pull.js +2 -0
  31. package/dist/commands/menu/push.d.ts +7 -1
  32. package/dist/commands/menu/push.js +3 -1
  33. package/dist/commands/meta/diff.d.ts +2 -0
  34. package/dist/commands/meta/diff.js +5 -0
  35. package/dist/commands/meta/list.d.ts +19 -0
  36. package/dist/commands/meta/list.js +21 -0
  37. package/dist/commands/meta/pull.d.ts +2 -0
  38. package/dist/commands/meta/pull.js +4 -0
  39. package/dist/commands/meta/push.d.ts +2 -0
  40. package/dist/commands/meta/push.js +4 -0
  41. package/dist/commands/meta/rm.d.ts +2 -0
  42. package/dist/commands/meta/rm.js +3 -0
  43. package/dist/commands/meta/rollback.d.ts +2 -0
  44. package/dist/commands/meta/rollback.js +5 -0
  45. package/dist/commands/option/list.js +2 -8
  46. package/dist/commands/option/pull.js +4 -4
  47. package/dist/commands/option/push.js +2 -0
  48. package/dist/commands/page/push.js +3 -0
  49. package/dist/commands/pattern/diff.d.ts +2 -0
  50. package/dist/commands/pattern/diff.js +5 -0
  51. package/dist/commands/pattern/list.d.ts +2 -0
  52. package/dist/commands/pattern/list.js +3 -0
  53. package/dist/commands/pattern/pull.d.ts +2 -0
  54. package/dist/commands/pattern/pull.js +4 -0
  55. package/dist/commands/pattern/push.d.ts +2 -0
  56. package/dist/commands/pattern/push.js +4 -0
  57. package/dist/commands/pattern/rm.d.ts +2 -0
  58. package/dist/commands/pattern/rm.js +3 -0
  59. package/dist/commands/pattern/rollback.d.ts +2 -0
  60. package/dist/commands/pattern/rollback.js +5 -0
  61. package/dist/commands/plugin/audit.js +2 -1
  62. package/dist/commands/plugin/pull.d.ts +4 -0
  63. package/dist/commands/plugin/pull.js +56 -10
  64. package/dist/commands/plugin/push.js +5 -4
  65. package/dist/commands/project/config.d.ts +1 -0
  66. package/dist/commands/project/config.js +20 -1
  67. package/dist/commands/project/list.d.ts +1 -1
  68. package/dist/commands/project/list.js +6 -6
  69. package/dist/commands/project/pull.js +11 -8
  70. package/dist/commands/project/push.js +23 -18
  71. package/dist/commands/project/remove.js +1 -1
  72. package/dist/commands/project/switch.d.ts +0 -1
  73. package/dist/commands/project/switch.js +16 -36
  74. package/dist/commands/pull.js +7 -2
  75. package/dist/commands/push.js +14 -6
  76. package/dist/commands/role/diff.d.ts +2 -0
  77. package/dist/commands/role/diff.js +5 -0
  78. package/dist/commands/role/list.d.ts +2 -0
  79. package/dist/commands/role/list.js +4 -0
  80. package/dist/commands/role/pull.d.ts +2 -0
  81. package/dist/commands/role/pull.js +4 -0
  82. package/dist/commands/role/push.d.ts +2 -0
  83. package/dist/commands/role/push.js +4 -0
  84. package/dist/commands/role/rm.d.ts +2 -0
  85. package/dist/commands/role/rm.js +3 -0
  86. package/dist/commands/role/rollback.d.ts +2 -0
  87. package/dist/commands/role/rollback.js +5 -0
  88. package/dist/commands/snippet/publish.js +1 -1
  89. package/dist/commands/snippet/pull.js +1 -1
  90. package/dist/commands/snippet/push.js +1 -1
  91. package/dist/commands/status.d.ts +3 -3
  92. package/dist/commands/status.js +21 -53
  93. package/dist/commands/taxonomy/diff.d.ts +2 -0
  94. package/dist/commands/taxonomy/diff.js +5 -0
  95. package/dist/commands/taxonomy/list.d.ts +2 -0
  96. package/dist/commands/taxonomy/list.js +5 -0
  97. package/dist/commands/taxonomy/pull.d.ts +2 -0
  98. package/dist/commands/taxonomy/pull.js +4 -0
  99. package/dist/commands/taxonomy/push.d.ts +2 -0
  100. package/dist/commands/taxonomy/push.js +4 -0
  101. package/dist/commands/taxonomy/rm.d.ts +2 -0
  102. package/dist/commands/taxonomy/rm.js +3 -0
  103. package/dist/commands/taxonomy/rollback.d.ts +2 -0
  104. package/dist/commands/taxonomy/rollback.js +5 -0
  105. package/dist/commands/theme/pull.d.ts +6 -10
  106. package/dist/commands/theme/pull.js +30 -32
  107. package/dist/commands/theme/push.d.ts +8 -9
  108. package/dist/commands/theme/push.js +54 -111
  109. package/dist/commands/theme/style/pull.d.ts +7 -1
  110. package/dist/commands/theme/style/pull.js +3 -1
  111. package/dist/commands/theme/style/push.d.ts +8 -1
  112. package/dist/commands/theme/style/push.js +4 -1
  113. package/dist/commands/{template → theme/template}/diff.d.ts +1 -1
  114. package/dist/commands/{template → theme/template}/diff.js +3 -3
  115. package/dist/commands/{template → theme/template}/list.d.ts +2 -2
  116. package/dist/commands/{template → theme/template}/list.js +5 -5
  117. package/dist/commands/{template → theme/template}/push.d.ts +3 -1
  118. package/dist/commands/theme/template/push.js +65 -0
  119. package/dist/commands/theme/version/pull.d.ts +23 -0
  120. package/dist/commands/theme/version/pull.js +43 -0
  121. package/dist/commands/theme/version/push.d.ts +22 -0
  122. package/dist/commands/theme/version/push.js +123 -0
  123. package/dist/config/project-config.manager.d.ts +3 -3
  124. package/dist/config/project-config.manager.js +22 -31
  125. package/dist/hooks/finally.d.ts +2 -1
  126. package/dist/hooks/finally.js +38 -7
  127. package/dist/lib/api-client.js +2 -1
  128. package/dist/lib/app-build.js +3 -1
  129. package/dist/lib/app-manifest.js +2 -2
  130. package/dist/lib/base.d.ts +2 -2
  131. package/dist/lib/base.js +33 -38
  132. package/dist/lib/declared-type-command.d.ts +35 -0
  133. package/dist/lib/declared-type-command.js +192 -0
  134. package/dist/lib/dev-watch.d.ts +1 -1
  135. package/dist/lib/dev-watch.js +7 -3
  136. package/dist/lib/diff-state.js +1 -0
  137. package/dist/lib/github-release.js +4 -1
  138. package/dist/lib/local-callback-server.d.ts +12 -6
  139. package/dist/lib/local-callback-server.js +24 -23
  140. package/dist/lib/php-files-command.js +3 -1
  141. package/dist/lib/push-command.d.ts +6 -0
  142. package/dist/lib/push-command.js +61 -18
  143. package/dist/lib/put-or-create.js +3 -4
  144. package/dist/lib/resource-state.js +40 -86
  145. package/dist/lib/rollback-materialize.js +13 -24
  146. package/dist/lib/snapshot-store.js +2 -2
  147. package/dist/lib/spinner.d.ts +2 -0
  148. package/dist/lib/spinner.js +72 -0
  149. package/dist/lib/temp-admin.d.ts +10 -1
  150. package/dist/lib/temp-admin.js +27 -2
  151. package/dist/lib/validate-local.js +31 -9
  152. package/dist/lib/wp-authorize-flow.d.ts +10 -17
  153. package/dist/lib/wp-authorize-flow.js +24 -28
  154. package/dist/lib/wp-client.d.ts +1 -0
  155. package/dist/lib/wp-client.js +7 -1
  156. package/dist/lib/wp-site-diagnostic.js +39 -1
  157. package/dist/types/global-config.generated.d.ts +1 -5
  158. package/dist/types/project-config.generated.d.ts +28 -4
  159. package/dist/utils/composer.d.ts +3 -1
  160. package/dist/utils/composer.js +16 -7
  161. package/dist/utils/declared-type-format.d.ts +52 -0
  162. package/dist/utils/declared-type-format.js +233 -0
  163. package/dist/utils/option-format.d.ts +3 -1
  164. package/dist/utils/option-format.js +14 -3
  165. package/dist/utils/page-format.d.ts +5 -1
  166. package/dist/utils/page-format.js +7 -3
  167. package/dist/utils/plugin-sync.js +1 -1
  168. package/dist/utils/resource-dirs.d.ts +9 -3
  169. package/dist/utils/resource-dirs.js +18 -6
  170. package/dist/utils/template-format.d.ts +3 -1
  171. package/dist/utils/template-format.js +10 -3
  172. package/dist/utils/theme-css.d.ts +19 -0
  173. package/dist/utils/theme-css.js +100 -0
  174. package/oclif.manifest.json +3542 -1087
  175. package/package.json +40 -21
  176. package/schemas/global-config.schema.json +2 -6
  177. package/schemas/project-config.schema.json +37 -7
  178. package/dist/commands/seo/diff.js +0 -5
  179. package/dist/commands/seo/list.d.ts +0 -18
  180. package/dist/commands/seo/list.js +0 -58
  181. package/dist/commands/seo/pull.d.ts +0 -17
  182. package/dist/commands/seo/pull.js +0 -87
  183. package/dist/commands/seo/push.d.ts +0 -23
  184. package/dist/commands/seo/push.js +0 -176
  185. package/dist/commands/seo/rollback.js +0 -5
  186. package/dist/commands/template/push.js +0 -42
  187. package/dist/utils/seo-format.d.ts +0 -29
  188. package/dist/utils/seo-format.js +0 -18
  189. /package/dist/commands/{seo → cpt}/diff.d.ts +0 -0
  190. /package/dist/commands/{seo → cpt}/rollback.d.ts +0 -0
@@ -0,0 +1,3 @@
1
+ import { declaredRmCommand } from '../../lib/declared-type-command.js';
2
+ import { META_SPEC } from '../../utils/declared-type-format.js';
3
+ export default declaredRmCommand(META_SPEC, 'Stop registering the Loopress meta keys of one post type (its meta/<post-type>.json file) on WordPress. Their values are kept in the database.', "Their values are kept in the database, hidden until it's pushed again.");
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/base.js").LoopressCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceRollbackCommand } from '../../lib/rollback-command.js';
2
+ export default resourceRollbackCommand('meta', {
3
+ description: 'Restore post meta keys to a snapshot taken automatically before an earlier `lps meta push`',
4
+ pathNoun: 'meta directory',
5
+ });
@@ -1,6 +1,7 @@
1
1
  import { Flags, ux } from '@oclif/core';
2
2
  import ttyTable from 'tty-table';
3
3
  import { LoopressCommand } from '../../lib/base.js';
4
+ import { withSpinner } from '../../lib/spinner.js';
4
5
  import { OPTIONS_ENDPOINT } from '../../utils/option-format.js';
5
6
  import { pluralize } from '../../utils/pluralize.js';
6
7
  // Same green/yellow pairing drives both SOURCE? and PLUGIN, so a row's whole "where did this come
@@ -127,14 +128,7 @@ export default class List extends LoopressCommand {
127
128
  // Every call scans active plugins' own PHP source for names the naming guess missed (not
128
129
  // opt-in, roughly 1-3s on a real site once vendor/tests/languages are pruned from the scan;
129
130
  // 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
- }
131
+ let options = await withSpinner('Fetching and verifying options', async () => this.wp.get(OPTIONS_ENDPOINT));
138
132
  if (flags['no-core'])
139
133
  options = options.filter((option) => !option.core);
140
134
  if (options.length === 0) {
@@ -56,19 +56,19 @@ export default class Pull extends LoopressCommand {
56
56
  }
57
57
  // Returns the refreshed option (and writes it to disk), or null when the tracked name is a
58
58
  // 404 on this environment: absent, not a failure (see the orphan reconciliation in run()).
59
- // readonly is a local policy flag with no WordPress counterpart, always preserved from the
60
- // existing file rather than overwritten by the pulled value.
59
+ // readonly and refs are local declarations with no WordPress counterpart, always preserved
60
+ // from the existing file rather than overwritten by the pulled value.
61
61
  async pullOption(dir, option) {
62
62
  let remote;
63
63
  try {
64
- remote = await this.wp.get(optionEndpoint(option.name));
64
+ remote = await this.wp.get(optionEndpoint(option.name, option.refs));
65
65
  }
66
66
  catch (error) {
67
67
  if (isNotFoundError(error))
68
68
  return null;
69
69
  throw error;
70
70
  }
71
- const local = { ...remote, readonly: option.readonly };
71
+ const local = { ...remote, readonly: option.readonly, refs: option.refs };
72
72
  await writeFile(join(dir, optionFileName(option.name)), JSON.stringify(local, null, 2) + '\n');
73
73
  return local;
74
74
  }
@@ -78,6 +78,8 @@ export default class Push extends PushCommand {
78
78
  // create): falls back to today's unconditional write, same as before this existed.
79
79
  const expectedRevision = await this.currentRevision(option.name);
80
80
  const body = { autoload: option.autoload, value: option.value };
81
+ if (option.refs)
82
+ body.refs = option.refs;
81
83
  if (expectedRevision !== undefined)
82
84
  body.expectedRevision = expectedRevision;
83
85
  await this.wp.put(optionEndpoint(option.name), body);
@@ -1,5 +1,6 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { PushCommand } from '../../lib/push-command.js';
3
+ import { getResourceStateProvider } from '../../lib/resource-state.js';
3
4
  import { formatPageProblems, frontPageNote, PAGES_ENDPOINT, readLocalPages } from '../../utils/page-format.js';
4
5
  import { pluralize } from '../../utils/pluralize.js';
5
6
  import { resolveResourceDir } from '../../utils/resource-dirs.js';
@@ -28,6 +29,8 @@ export default class Push extends PushCommand {
28
29
  this.error(`No page "${args.slug}" in ${path} (expected ${args.slug}.html).`);
29
30
  }
30
31
  this.log(`Found ${pluralize(selected.length, 'page')} to push`);
32
+ if (this.dryRun)
33
+ await this.previewRemote(getResourceStateProvider('page'), path);
31
34
  const pushed = [];
32
35
  await this.runPushTasks(selected, (page) => page.slug, async (page, task) => {
33
36
  await this.pushPage(page, task);
@@ -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('pattern', {
3
+ description: 'Show what differs in block patterns between your local patterns/ files and a WordPress environment, or between two environments',
4
+ pathNoun: 'patterns directory',
5
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/declared-type-command.js").DeclaredListCommand>;
2
+ export default _default;
@@ -0,0 +1,3 @@
1
+ import { declaredListCommand } from '../../lib/declared-type-command.js';
2
+ import { PATTERN_SPEC } from '../../utils/declared-type-format.js';
3
+ export default declaredListCommand(PATTERN_SPEC, 'List the block patterns registered on WordPress by Loopress, the theme and plugins (not WordPress core). Only Loopress ones are pulled and pushed.', () => '');
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/declared-type-command.js").DeclaredPullCommand>;
2
+ export default _default;
@@ -0,0 +1,4 @@
1
+ import { declaredPullCommand } from '../../lib/declared-type-command.js';
2
+ import { PATTERN_SPEC } from '../../utils/declared-type-format.js';
3
+ export default declaredPullCommand(PATTERN_SPEC, 'Pull the block patterns Loopress manages on WordPress into patterns/<slug>.html files. ' +
4
+ 'Patterns registered by the theme or a plugin are never pulled, see `lps pattern list`.');
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/declared-type-command.js").DeclaredPushCommand>;
2
+ export default _default;
@@ -0,0 +1,4 @@
1
+ import { declaredPushCommand } from '../../lib/declared-type-command.js';
2
+ import { PATTERN_SPEC } from '../../utils/declared-type-format.js';
3
+ export default declaredPushCommand(PATTERN_SPEC, 'Push local block patterns (patterns/<slug>.html, block markup under a title/categories header) to WordPress, ' +
4
+ 'registered as loopress/<slug>. Create or update only, never deletes a pattern (use `lps pattern rm`).');
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/declared-type-command.js").DeclaredRmCommand>;
2
+ export default _default;
@@ -0,0 +1,3 @@
1
+ import { declaredRmCommand } from '../../lib/declared-type-command.js';
2
+ import { PATTERN_SPEC } from '../../utils/declared-type-format.js';
3
+ export default declaredRmCommand(PATTERN_SPEC, 'Stop registering a Loopress block pattern on WordPress. A wp:pattern reference to it renders nothing, copies already inserted in posts stay.', 'A wp:pattern reference to it renders nothing until it is pushed again, copies already inserted in posts stay.');
@@ -0,0 +1,2 @@
1
+ declare const _default: typeof import("../../lib/base.js").LoopressCommand;
2
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { resourceRollbackCommand } from '../../lib/rollback-command.js';
2
+ export default resourceRollbackCommand('pattern', {
3
+ description: 'Restore block patterns to a snapshot taken automatically before an earlier `lps pattern push`',
4
+ pathNoun: 'patterns directory',
5
+ });
@@ -1,5 +1,6 @@
1
1
  import got from 'got';
2
2
  import { LoopressCommand } from '../../lib/base.js';
3
+ import { withSpinner } from '../../lib/spinner.js';
3
4
  import { pinVersion } from '../../types/plugin.js';
4
5
  import { compareVersions } from '../../utils/version.js';
5
6
  const WPVULN_BASE = 'https://www.wpvulnerability.net/plugin';
@@ -19,7 +20,7 @@ export default class Audit extends LoopressCommand {
19
20
  const advisories = [];
20
21
  const health = [];
21
22
  for (const slug of slugs) {
22
- const found = await this.auditOne(slug, pinVersion(manifest[slug]));
23
+ const found = await withSpinner(`Auditing ${slug}`, async () => this.auditOne(slug, pinVersion(manifest[slug])));
23
24
  advisories.push(...found.advisories);
24
25
  health.push(...found.health);
25
26
  }
@@ -4,14 +4,18 @@ import { type MergeResult } from '../../utils/plugins.js';
4
4
  type PullResult = MergeResult & {
5
5
  skipped?: ComposerPullResult['skipped'];
6
6
  status: 'dry-run' | 'success';
7
+ untracked: string[];
7
8
  };
8
9
  export default class Pull extends LoopressCommand {
9
10
  static description: string;
10
11
  static enableJsonFlag: boolean;
11
12
  static examples: string[];
12
13
  static flags: {
14
+ plugin: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
17
  };
15
18
  run(): Promise<PullResult>;
19
+ private selectPlugins;
16
20
  }
17
21
  export {};
@@ -1,36 +1,60 @@
1
+ import { checkbox } from '@inquirer/prompts';
2
+ import { Flags } from '@oclif/core';
1
3
  import { existsSync } from 'node:fs';
4
+ import { readFile } from 'node:fs/promises';
2
5
  import { join } from 'node:path';
3
6
  import { LoopressCommand } from '../../lib/base.js';
4
- import { pullIntoComposerJson } from '../../utils/composer.js';
7
+ import { isInteractive } from '../../lib/interactive.js';
8
+ import { getComposerManagedSlugs, NOT_ON_WORDPRESS_ORG, pullIntoComposerJson, } from '../../utils/composer.js';
5
9
  import { writeLocalConfig } from '../../utils/loopress-config.js';
6
10
  import { mergePluginManifest, parseInstalledPlugins } from '../../utils/plugins.js';
7
11
  export default class Pull extends LoopressCommand {
8
- static description = 'Pull installed plugins from WordPress into loopress.json, pinned to their live versions';
12
+ static description = 'Pull installed plugins from WordPress into loopress.json (or composer.json), pinned to their live versions. ' +
13
+ 'Plugins already tracked are refreshed; new ones are offered as a list to tick, so plugins that only belong ' +
14
+ 'on this environment (cache, security, backups) stay out of the project.';
9
15
  static enableJsonFlag = true;
10
- static examples = ['$ lps plugin pull', '$ lps plugin pull --dry-run'];
16
+ static examples = ['$ lps plugin pull', '$ lps plugin pull --env production --plugin query-monitor'];
11
17
  static flags = {
12
18
  ...LoopressCommand.dryRunFlag,
19
+ ...LoopressCommand.yesFlag,
20
+ plugin: Flags.string({
21
+ description: 'Start tracking this installed plugin too, without asking (repeatable)',
22
+ multiple: true,
23
+ }),
13
24
  };
14
25
  async run() {
26
+ const { flags } = await this.parse(Pull);
15
27
  const { url } = this.siteConfig;
16
28
  this.log(`Pulling plugins from ${url}`);
17
29
  const raw = await this.wp.get('wp/v2/plugins');
18
- const installed = parseInstalledPlugins(raw);
30
+ const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
31
+ const composerJson = existsSync(composerJsonPath)
32
+ ? JSON.parse(await readFile(composerJsonPath, 'utf8'))
33
+ : undefined;
34
+ const declared = new Set(composerJson ? getComposerManagedSlugs(composerJson) : Object.keys(this.localConfig.plugins ?? {}));
35
+ const all = parseInstalledPlugins(raw);
36
+ const selected = await this.selectPlugins(all, declared, flags.plugin ?? [], flags.yes);
37
+ const installed = all.filter((p) => selected.has(p.slug));
38
+ const untracked = all.filter((p) => !selected.has(p.slug)).map((p) => p.slug);
39
+ if (untracked.length > 0) {
40
+ this.log(` - Not tracked: ${untracked.join(', ')} (add one with \`lps plugin pull --plugin <slug>\`)`);
41
+ }
19
42
  // Pin every plugin to the version actually running on the site, and record the inactive ones
20
43
  // as such so a later `plugin push` doesn't switch them on. A later `plugin push` installs
21
44
  // exactly this set via Composer + WPackagist; drift only surfaces when the pinned version and
22
45
  // the live version disagree.
23
46
  const versions = Object.fromEntries(installed.map((p) => [p.slug, p.version]));
24
- const pins = Object.fromEntries(installed.map((p) => [p.slug, p.active ? p.version : { active: false, version: p.version }]));
25
47
  // A composer.json is authoritative over loopress.json: pin the live versions there instead.
26
- const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
27
- if (existsSync(composerJsonPath)) {
48
+ if (composerJson) {
28
49
  const result = await pullIntoComposerJson(composerJsonPath, 'plugin', versions, {
29
50
  dryRun: this.dryRun,
30
51
  log: this.log.bind(this),
31
52
  });
32
- return { ...result, status: this.dryRun ? 'dry-run' : 'success' };
53
+ // Ticked, but Composer can't install it (premium or custom): still not tracked by the project.
54
+ const notInstallable = result.skipped.filter((s) => s.reason === NOT_ON_WORDPRESS_ORG).map((s) => s.slug);
55
+ return { ...result, status: this.dryRun ? 'dry-run' : 'success', untracked: [...untracked, ...notInstallable] };
33
56
  }
57
+ const pins = Object.fromEntries(installed.map((p) => [p.slug, p.active ? p.version : { active: false, version: p.version }]));
34
58
  const { added, merged, updated } = mergePluginManifest(this.localConfig.plugins ?? {}, pins);
35
59
  if (this.dryRun) {
36
60
  this.log(`[dry-run] Would write ${Object.keys(merged).length} plugins to loopress.json`);
@@ -38,7 +62,7 @@ export default class Pull extends LoopressCommand {
38
62
  this.log(` + ${added.join(', ')}`);
39
63
  for (const u of updated)
40
64
  this.log(` ~ ${u.slug} (${u.from} → ${u.to})`);
41
- return { added, merged, status: 'dry-run', updated };
65
+ return { added, merged, status: 'dry-run', untracked, updated };
42
66
  }
43
67
  await writeLocalConfig({ ...this.localConfig, plugins: merged });
44
68
  this.log(`Wrote ${Object.keys(merged).length} plugins to loopress.json`);
@@ -46,6 +70,28 @@ export default class Pull extends LoopressCommand {
46
70
  this.log(` + Added: ${added.join(', ')}`);
47
71
  for (const u of updated)
48
72
  this.log(` ~ Updated: ${u.slug} ${u.from} → ${u.to}`);
49
- return { added, merged, status: 'success', updated };
73
+ return { added, merged, status: 'success', untracked, updated };
74
+ }
75
+ // Plugins already tracked are always refreshed, plus any --plugin. Every other installed plugin
76
+ // is offered unticked, so a second pull from production never brings Wordfence back on its
77
+ // own. With nobody to ask (--yes, --json, CI, the MCP server), only those two sets count:
78
+ // "track everything" would bring the same problem back silently.
79
+ async selectPlugins(installed, declared, requested, yes) {
80
+ const slugs = new Set(installed.map((p) => p.slug));
81
+ for (const slug of requested) {
82
+ if (!slugs.has(slug))
83
+ this.warn(`--plugin ${slug}: not installed on this environment, skipped.`);
84
+ }
85
+ const selected = new Set([...slugs].filter((slug) => declared.has(slug) || requested.includes(slug)));
86
+ const candidates = installed.filter((p) => !selected.has(p.slug));
87
+ if (yes || candidates.length === 0 || this.jsonEnabled() || !isInteractive())
88
+ return selected;
89
+ const picked = await checkbox({
90
+ choices: candidates.map((p) => ({ name: `${p.slug} ${p.version}${p.active ? '' : ' (inactive)'}`, value: p.slug })),
91
+ message: 'Which of these plugins should the project track? Leave the ones that only belong on this environment unticked.',
92
+ });
93
+ for (const slug of picked)
94
+ selected.add(slug);
95
+ return selected;
50
96
  }
51
97
  }
@@ -4,9 +4,9 @@ import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { confirmUninstall } from '../../lib/interactive.js';
6
6
  import { PushCommand } from '../../lib/push-command.js';
7
- import { isNotFoundError } from '../../lib/wp-client.js';
7
+ import { isLocalEnvironment, isNotFoundError } from '../../lib/wp-client.js';
8
8
  import { pinActive, pinVersion, pinVersions, } from '../../types/plugin.js';
9
- import { toIntent, wpackagistRequire } from '../../utils/composer.js';
9
+ import { requireToInstall, toIntent, wpackagistRequire } from '../../utils/composer.js';
10
10
  import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
11
11
  import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins } from '../../utils/plugins.js';
12
12
  import { isExactVersion } from '../../utils/version.js';
@@ -178,11 +178,12 @@ export default class Push extends PushCommand {
178
178
  const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
179
179
  if (existsSync(composerJsonPath)) {
180
180
  const composerJson = JSON.parse(await readFile(composerJsonPath, 'utf8'));
181
- const manifest = wpackagistRequire(composerJson, 'plugin');
181
+ const includeDev = isLocalEnvironment(this.siteConfig.name);
182
+ const manifest = wpackagistRequire(composerJson, 'plugin', includeDev);
182
183
  if (Object.keys(manifest).length === 0) {
183
184
  this.error('No wpackagist-plugin/* package in composer.json. Run `lps plugin pull` first, or `lps composer push` to push it as is.');
184
185
  }
185
- const require = composerJson.require ?? {};
186
+ const require = requireToInstall(composerJson, includeDev);
186
187
  return { file: 'composer.json', intent: toIntent(require), manifest, packageCount: Object.keys(require).length };
187
188
  }
188
189
  if (activate) {
@@ -7,4 +7,5 @@ export default class Config extends Command {
7
7
  private promptManualCredentials;
8
8
  private resolveCredentials;
9
9
  private resolveProject;
10
+ private sweepLeftoverTempAdmins;
10
11
  }
@@ -4,9 +4,12 @@ import { configManager } from '../../config/project-config.manager.js';
4
4
  import { bootstrapLoopressFull } from '../../lib/bootstrap-full-install.js';
5
5
  import { isInteractive } from '../../lib/interactive.js';
6
6
  import { isLoopressFullActive } from '../../lib/plugin-detection.js';
7
+ import { withSpinner } from '../../lib/spinner.js';
8
+ import { sweepTempAdmins } from '../../lib/temp-admin.js';
7
9
  import { authorizeWithBrowser } from '../../lib/wp-authorize-flow.js';
8
10
  import { isLocalEnvironment, WpClient } from '../../lib/wp-client.js';
9
11
  import { diagnoseWpSite } from '../../lib/wp-site-diagnostic.js';
12
+ import { pluralize } from '../../utils/pluralize.js';
10
13
  const NEW_PROJECT = '__new__';
11
14
  const AUTH_BROWSER = 'browser';
12
15
  const AUTH_MANUAL = 'manual';
@@ -96,6 +99,10 @@ export default class Config extends Command {
96
99
  if (!token)
97
100
  return; // env was just built with one a few lines up; guard only narrows the type
98
101
  const wp = new WpClient(url, token, name);
102
+ // Before the "already active" early return: an install killed mid-way (SIGKILL, power loss)
103
+ // may have activated Full and still left its temp admin behind, and re-running this command
104
+ // is the documented fix for both.
105
+ await this.sweepLeftoverTempAdmins(wp);
99
106
  // A detection failure (network hiccup, plugins REST disabled) is treated as "assume it's
100
107
  // there" rather than surprising the user with an account-creating prompt they didn't ask
101
108
  // for; `lps project config` can simply be re-run if it genuinely isn't installed.
@@ -109,7 +116,9 @@ export default class Config extends Command {
109
116
  if (!isProceed)
110
117
  return;
111
118
  try {
112
- await bootstrapLoopressFull(wp, url, (message) => { this.log(message); });
119
+ await withSpinner('Installing Loopress Full', async () => bootstrapLoopressFull(wp, url, (message) => {
120
+ this.log(message);
121
+ }));
113
122
  }
114
123
  catch (error) {
115
124
  this.warn(error.message);
@@ -181,4 +190,14 @@ export default class Config extends Command {
181
190
  });
182
191
  return { projectId: configManager.createProjectId(projectName.trim()), projectName: projectName.trim() };
183
192
  }
193
+ async sweepLeftoverTempAdmins(wp) {
194
+ try {
195
+ const removed = await sweepTempAdmins(wp);
196
+ if (removed.length > 0)
197
+ this.log(`✓ Removed ${pluralize(removed.length, 'leftover temporary admin account')}: ${removed.join(', ')}`);
198
+ }
199
+ catch (error) {
200
+ this.warn(error.message);
201
+ }
202
+ }
184
203
  }
@@ -1,6 +1,6 @@
1
1
  import { Command } from '@oclif/core';
2
2
  type EnvironmentInfo = {
3
- isCurrent: boolean;
3
+ isDefault: boolean;
4
4
  name: string;
5
5
  url: string;
6
6
  };
@@ -14,15 +14,15 @@ export default class List extends Command {
14
14
  }
15
15
  return projects.map((project) => this.logProject(project));
16
16
  }
17
- // Only name/url/isCurrent are surfaced, deliberately: EnvironmentConfig also carries the
17
+ // Only name/url/isDefault are surfaced, deliberately: EnvironmentConfig also carries the
18
18
  // WordPress application password token, which must never end up in --json output.
19
19
  logEnvironment(env) {
20
- const envMarker = c(env.isCurrent ? 'cyan' : 'dim', '·');
21
- const envName = c(env.isCurrent ? 'cyan' : 'dim', env.name.padEnd(15));
20
+ const envMarker = c(env.isDefault ? 'cyan' : 'dim', '·');
21
+ const envName = c(env.isDefault ? 'cyan' : 'dim', env.name.padEnd(15));
22
22
  const envUrl = c('dim', env.url);
23
- const activeTag = env.isCurrent ? ` ${c('cyan', '←')}` : '';
24
- this.log(` ${envMarker} ${envName} ${envUrl}${activeTag}`);
25
- return { isCurrent: env.isCurrent, name: env.name, url: env.url };
23
+ const defaultTag = env.isDefault ? ` ${c('cyan', '[default]')}` : '';
24
+ this.log(` ${envMarker} ${envName} ${envUrl}${defaultTag}`);
25
+ return { isDefault: env.isDefault, name: env.name, url: env.url };
26
26
  }
27
27
  logProject(project) {
28
28
  const marker = project.isCurrent ? c('green', '●') : c('dim', '○');
@@ -3,6 +3,7 @@ import { Listr } from 'listr2';
3
3
  import { authManager } from '../../config/auth.manager.js';
4
4
  import { configManager } from '../../config/project-config.manager.js';
5
5
  import { ApiClient } from '../../lib/api-client.js';
6
+ import { withoutSpinner } from '../../lib/spinner.js';
6
7
  import { pluralize } from '../../utils/pluralize.js';
7
8
  export default class Pull extends Command {
8
9
  static description = 'Pull projects and environments from your Loopress account that are not configured locally yet';
@@ -23,14 +24,16 @@ export default class Pull extends Command {
23
24
  let projectCount = 0;
24
25
  let environmentCount = 0;
25
26
  if (newApiProjects.length > 0) {
26
- await new Listr(newApiProjects.map((apiProject) => ({
27
- task: (_ctx, task) => {
28
- this.pullProject(apiProject, task);
29
- projectCount++;
30
- environmentCount += apiProject.environments.length;
31
- },
32
- title: `Pull project "${apiProject.name}" from the API`,
33
- })), { concurrent: false, exitOnError: false }).run();
27
+ await withoutSpinner(async () => {
28
+ await new Listr(newApiProjects.map((apiProject) => ({
29
+ task: (_ctx, task) => {
30
+ this.pullProject(apiProject, task);
31
+ projectCount++;
32
+ environmentCount += apiProject.environments.length;
33
+ },
34
+ title: `Pull project "${apiProject.name}" from the API`,
35
+ })), { concurrent: false, exitOnError: false }).run();
36
+ });
34
37
  }
35
38
  this.log(`\n✓ Pulled ${pluralize(projectCount, 'project')}, ${pluralize(environmentCount, 'environment')} from your Loopress account`);
36
39
  }
@@ -6,6 +6,7 @@ import { configManager } from '../../config/project-config.manager.js';
6
6
  import { ApiClient } from '../../lib/api-client.js';
7
7
  import { LoopressCommand } from '../../lib/base.js';
8
8
  import { isInteractive } from '../../lib/interactive.js';
9
+ import { withoutSpinner } from '../../lib/spinner.js';
9
10
  import { pluralize } from '../../utils/pluralize.js';
10
11
  import { toSlug } from '../../utils/to-slug.js';
11
12
  export default class Push extends Command {
@@ -66,15 +67,17 @@ export default class Push extends Command {
66
67
  if (envsNeedingWork.length === 0)
67
68
  return 0;
68
69
  let count = 0;
69
- await new Listr(envsNeedingWork.map(({ apiProjectId, envPlan, projectName }) => ({
70
- task: async (_ctx, task) => {
71
- await this.applyEnvironment(api, apiProjectId, envPlan, task);
72
- count++;
73
- },
74
- title: envPlan.action === 'create'
75
- ? `Create environment "${envPlan.env.name}" on "${projectName}"`
76
- : `Link environment "${envPlan.env.name}" on "${projectName}"`,
77
- })), { concurrent: false, exitOnError: false }).run();
70
+ await withoutSpinner(async () => {
71
+ await new Listr(envsNeedingWork.map(({ apiProjectId, envPlan, projectName }) => ({
72
+ task: async (_ctx, task) => {
73
+ await this.applyEnvironment(api, apiProjectId, envPlan, task);
74
+ count++;
75
+ },
76
+ title: envPlan.action === 'create'
77
+ ? `Create environment "${envPlan.env.name}" on "${projectName}"`
78
+ : `Link environment "${envPlan.env.name}" on "${projectName}"`,
79
+ })), { concurrent: false, exitOnError: false }).run();
80
+ });
78
81
  return count;
79
82
  }
80
83
  async applyProject(api, plan, task) {
@@ -101,15 +104,17 @@ export default class Push extends Command {
101
104
  if (projectsNeedingWork.length === 0)
102
105
  return 0;
103
106
  let count = 0;
104
- await new Listr(projectsNeedingWork.map((plan) => ({
105
- task: async (_ctx, task) => {
106
- await this.applyProject(api, plan, task);
107
- count++;
108
- },
109
- title: plan.action === 'create'
110
- ? `Create project "${plan.project.name}" on the API`
111
- : `Link project "${plan.project.name}" to the API`,
112
- })), { concurrent: false, exitOnError: false }).run();
107
+ await withoutSpinner(async () => {
108
+ await new Listr(projectsNeedingWork.map((plan) => ({
109
+ task: async (_ctx, task) => {
110
+ await this.applyProject(api, plan, task);
111
+ count++;
112
+ },
113
+ title: plan.action === 'create'
114
+ ? `Create project "${plan.project.name}" on the API`
115
+ : `Link project "${plan.project.name}" to the API`,
116
+ })), { concurrent: false, exitOnError: false }).run();
117
+ });
113
118
  return count;
114
119
  }
115
120
  collectEnvsNeedingWork(projectPlans, envPlansByProject) {
@@ -24,7 +24,7 @@ export default class Remove extends Command {
24
24
  const envChoices = configManager.listEnvironments(project.id).map((env) => {
25
25
  targets.push({ env: env.name, kind: 'env', projectId: project.id, projectName: project.name });
26
26
  return {
27
- name: ` ${env.name.padEnd(20)} ${env.url}${env.isCurrent ? ' [current]' : ''}`,
27
+ name: ` ${env.name.padEnd(20)} ${env.url}`,
28
28
  value: String(targets.length - 1),
29
29
  };
30
30
  });
@@ -3,5 +3,4 @@ export default class Switch extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  run(): Promise<void>;
6
- private resolveSelection;
7
6
  }
@@ -1,9 +1,8 @@
1
- import { select, Separator } from '@inquirer/prompts';
2
- import { Command, ux } from '@oclif/core';
1
+ import { select } from '@inquirer/prompts';
2
+ import { Command } from '@oclif/core';
3
3
  import { configManager } from '../../config/project-config.manager.js';
4
- const c = ux.colorize;
5
4
  export default class Switch extends Command {
6
- static description = 'Switch the active project and environment';
5
+ static description = 'Switch the active project (pass --env to target a non-local environment)';
7
6
  static examples = ['$ lps project switch'];
8
7
  async run() {
9
8
  await this.parse(Switch);
@@ -11,37 +10,18 @@ export default class Switch extends Command {
11
10
  if (projects.length === 0) {
12
11
  this.error('No projects configured. Run `lps project config` first.');
13
12
  }
14
- const { envName, projectId, projectName } = await this.resolveSelection(projects);
15
- configManager.setCurrent(projectId, envName);
16
- this.log(`✓ Switched to "${projectName}/${envName}"`);
17
- }
18
- async resolveSelection(projects) {
19
- const groups = projects
20
- .map((project) => ({ envs: configManager.listEnvironments(project.id), project }))
21
- .filter(({ envs }) => envs.length > 0);
22
- if (groups.length === 0) {
23
- this.error('No environments configured. Run `lps project config` first.');
24
- }
25
- const entries = groups.flatMap(({ envs, project }) => envs.map((env) => ({ env, project })));
26
- if (entries.length === 1) {
27
- const { env, project } = entries[0];
28
- return { envName: env.name, projectId: project.id, projectName: project.name };
29
- }
30
- const choices = groups.flatMap(({ envs, project }) => [
31
- new Separator(c(project.isCurrent ? 'green' : 'dim', `─── ${project.name} ───`)),
32
- ...envs.map((env) => ({
33
- name: `${env.name.padEnd(20)} ${env.url}${env.isCurrent ? ' [current]' : ''}`,
34
- value: `${project.id}::${env.name}`,
35
- })),
36
- ]);
37
- const current = entries.find(({ env }) => env.isCurrent);
38
- const chosen = await select({
39
- choices,
40
- default: current && `${current.project.id}::${current.env.name}`,
41
- message: 'Select project / environment',
42
- });
43
- const [projectId, envName] = chosen.split('::', 2);
44
- const { project } = groups.find((group) => group.project.id === projectId);
45
- return { envName, projectId, projectName: project.name };
13
+ const projectId = projects.length === 1
14
+ ? projects[0].id
15
+ : await select({
16
+ choices: projects.map((project) => ({
17
+ name: `${project.name}${project.isCurrent ? ' [current]' : ''}`,
18
+ value: project.id,
19
+ })),
20
+ default: projects.find((project) => project.isCurrent)?.id,
21
+ message: 'Select project',
22
+ });
23
+ configManager.setCurrent(projectId);
24
+ const project = projects.find((candidate) => candidate.id === projectId);
25
+ this.log(`✓ Switched to "${project.name}"`);
46
26
  }
47
27
  }