@loopress/cli 0.27.0 → 0.29.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 (124) hide show
  1. package/README.md +448 -189
  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/pull.js +17 -4
  9. package/dist/commands/app/push.d.ts +3 -0
  10. package/dist/commands/app/push.js +30 -6
  11. package/dist/commands/composer/push.js +3 -21
  12. package/dist/commands/diff.js +1 -1
  13. package/dist/commands/form/pull.d.ts +9 -1
  14. package/dist/commands/form/pull.js +3 -1
  15. package/dist/commands/form/push.d.ts +7 -1
  16. package/dist/commands/form/push.js +3 -1
  17. package/dist/commands/init.js +6 -36
  18. package/dist/commands/menu/pull.d.ts +7 -1
  19. package/dist/commands/menu/pull.js +2 -0
  20. package/dist/commands/menu/push.d.ts +7 -1
  21. package/dist/commands/menu/push.js +3 -1
  22. package/dist/commands/option/pull.js +4 -4
  23. package/dist/commands/option/push.js +2 -0
  24. package/dist/commands/page/push.js +3 -0
  25. package/dist/commands/plugin/add.js +6 -3
  26. package/dist/commands/plugin/audit.js +4 -5
  27. package/dist/commands/plugin/pull.d.ts +7 -1
  28. package/dist/commands/plugin/pull.js +68 -14
  29. package/dist/commands/plugin/push.d.ts +5 -1
  30. package/dist/commands/plugin/push.js +92 -38
  31. package/dist/commands/plugin/status.d.ts +1 -0
  32. package/dist/commands/plugin/status.js +21 -2
  33. package/dist/commands/project/config.js +12 -8
  34. package/dist/commands/promote.d.ts +2 -0
  35. package/dist/commands/promote.js +69 -8
  36. package/dist/commands/pull.js +2 -2
  37. package/dist/commands/push.d.ts +1 -0
  38. package/dist/commands/push.js +40 -5
  39. package/dist/commands/seo/pull.d.ts +8 -1
  40. package/dist/commands/seo/pull.js +2 -0
  41. package/dist/commands/seo/push.d.ts +7 -1
  42. package/dist/commands/seo/push.js +3 -1
  43. package/dist/commands/snippet/publish.js +1 -1
  44. package/dist/commands/snippet/pull.js +1 -1
  45. package/dist/commands/snippet/push.js +1 -1
  46. package/dist/commands/status.d.ts +1 -0
  47. package/dist/commands/status.js +4 -4
  48. package/dist/commands/theme/pull.d.ts +6 -8
  49. package/dist/commands/theme/pull.js +30 -26
  50. package/dist/commands/theme/push.d.ts +8 -9
  51. package/dist/commands/theme/push.js +54 -111
  52. package/dist/commands/theme/style/diff.d.ts +2 -0
  53. package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
  54. package/dist/commands/{theme-styles → theme/style}/pull.d.ts +8 -2
  55. package/dist/commands/{theme-styles → theme/style}/pull.js +6 -4
  56. package/dist/commands/{theme-styles → theme/style}/push.d.ts +9 -2
  57. package/dist/commands/{theme-styles → theme/style}/push.js +9 -6
  58. package/dist/commands/theme/style/rollback.d.ts +2 -0
  59. package/dist/commands/theme/style/rollback.js +6 -0
  60. package/dist/commands/theme/template/diff.d.ts +10 -0
  61. package/dist/commands/theme/template/diff.js +13 -0
  62. package/dist/commands/theme/template/list.d.ts +13 -0
  63. package/dist/commands/theme/template/list.js +23 -0
  64. package/dist/commands/theme/template/push.d.ts +20 -0
  65. package/dist/commands/theme/template/push.js +44 -0
  66. package/dist/commands/theme/version/pull.d.ts +23 -0
  67. package/dist/commands/theme/version/pull.js +43 -0
  68. package/dist/commands/theme/version/push.d.ts +22 -0
  69. package/dist/commands/theme/version/push.js +123 -0
  70. package/dist/hooks/finally.d.ts +2 -1
  71. package/dist/hooks/finally.js +27 -4
  72. package/dist/lib/api-client.js +3 -1
  73. package/dist/lib/app-build.d.ts +5 -0
  74. package/dist/lib/app-build.js +69 -0
  75. package/dist/lib/app-manifest.d.ts +4 -0
  76. package/dist/lib/app-manifest.js +10 -2
  77. package/dist/lib/base.d.ts +1 -0
  78. package/dist/lib/base.js +14 -2
  79. package/dist/lib/bootstrap-full-install.js +45 -14
  80. package/dist/lib/dev-watch.d.ts +1 -1
  81. package/dist/lib/dev-watch.js +3 -1
  82. package/dist/lib/diff-command.d.ts +1 -0
  83. package/dist/lib/diff-command.js +6 -4
  84. package/dist/lib/diff-state.js +1 -0
  85. package/dist/lib/github-release.js +0 -1
  86. package/dist/lib/local-callback-server.d.ts +12 -6
  87. package/dist/lib/local-callback-server.js +21 -22
  88. package/dist/lib/php-files-command.js +3 -1
  89. package/dist/lib/push-command.d.ts +5 -0
  90. package/dist/lib/push-command.js +30 -8
  91. package/dist/lib/resource-state.js +47 -4
  92. package/dist/lib/rollback-command.d.ts +1 -0
  93. package/dist/lib/rollback-command.js +8 -6
  94. package/dist/lib/rotate-app-password.js +2 -2
  95. package/dist/lib/snapshot-store.js +2 -2
  96. package/dist/lib/validate-local.js +6 -0
  97. package/dist/lib/wp-authorize-flow.d.ts +10 -17
  98. package/dist/lib/wp-authorize-flow.js +24 -28
  99. package/dist/lib/wp-client.d.ts +4 -1
  100. package/dist/lib/wp-client.js +25 -1
  101. package/dist/lib/wp-site-diagnostic.js +37 -0
  102. package/dist/types/plugin.d.ts +8 -1
  103. package/dist/types/plugin.js +4 -1
  104. package/dist/types/project-config.generated.d.ts +19 -2
  105. package/dist/utils/composer.d.ts +16 -0
  106. package/dist/utils/composer.js +144 -0
  107. package/dist/utils/option-format.d.ts +3 -1
  108. package/dist/utils/option-format.js +14 -3
  109. package/dist/utils/page-format.d.ts +12 -0
  110. package/dist/utils/page-format.js +40 -28
  111. package/dist/utils/plugin-sync.js +1 -1
  112. package/dist/utils/plugins.d.ts +9 -4
  113. package/dist/utils/plugins.js +16 -7
  114. package/dist/utils/resource-dirs.d.ts +2 -0
  115. package/dist/utils/resource-dirs.js +6 -2
  116. package/dist/utils/seo-format.d.ts +2 -2
  117. package/dist/utils/template-format.d.ts +31 -0
  118. package/dist/utils/template-format.js +43 -0
  119. package/oclif.manifest.json +841 -63
  120. package/package.json +24 -17
  121. package/schemas/project-config.schema.json +25 -4
  122. package/dist/commands/theme-styles/diff.d.ts +0 -2
  123. package/dist/commands/theme-styles/rollback.d.ts +0 -2
  124. package/dist/commands/theme-styles/rollback.js +0 -5
package/bin/dev.js CHANGED
File without changes
@@ -1,15 +1,23 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ import { type AcfObjectType } from '../../utils/acf-format.js';
3
+ type AcfPullResult = {
4
+ path: string;
5
+ status: 'dry-run' | 'success';
6
+ types: AcfObjectType[];
7
+ };
2
8
  export default class Pull extends LoopressCommand {
3
9
  static args: {
4
10
  path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
11
  };
6
12
  static description: string;
13
+ static enableJsonFlag: boolean;
7
14
  static examples: string[];
8
15
  static flags: {
9
16
  type: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
17
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
18
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
19
  };
13
- run(): Promise<void>;
20
+ run(): Promise<AcfPullResult>;
14
21
  private pullType;
15
22
  }
23
+ export {};
@@ -9,6 +9,7 @@ export default class Pull extends LoopressCommand {
9
9
  path: Args.string({ description: 'Path to ACF directory (overrides project config)' }),
10
10
  };
11
11
  static description = 'Pull ACF field groups, post types, taxonomies, and options pages from WordPress';
12
+ static enableJsonFlag = true;
12
13
  static examples = ['$ lps acf pull', '$ lps acf pull --type field-groups'];
13
14
  static flags = {
14
15
  ...LoopressCommand.dryRunFlag,
@@ -25,6 +26,7 @@ export default class Pull extends LoopressCommand {
25
26
  for (const type of types) {
26
27
  await this.pullType(type, path);
27
28
  }
29
+ return { path, status: this.dryRun ? 'dry-run' : 'success', types };
28
30
  }
29
31
  async pullType(type, basePath) {
30
32
  const dir = join(basePath, type);
@@ -1,18 +1,26 @@
1
1
  import { PushCommand } from '../../lib/push-command.js';
2
+ import { type AcfObjectType } from '../../utils/acf-format.js';
3
+ type AcfPushResult = {
4
+ path: string;
5
+ status: 'dry-run' | 'success';
6
+ types: AcfObjectType[];
7
+ };
2
8
  export default class Push extends PushCommand {
3
9
  static args: {
4
10
  path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
11
  };
6
12
  static description: string;
13
+ static enableJsonFlag: boolean;
7
14
  static examples: string[];
8
15
  static flags: {
9
16
  type: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
17
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
18
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
19
  };
13
- run(): Promise<void>;
20
+ run(): Promise<AcfPushResult>;
14
21
  private currentRevision;
15
22
  private loadObjects;
16
23
  private pushObject;
17
24
  private pushType;
18
25
  }
26
+ export {};
@@ -11,6 +11,7 @@ export default class Push extends PushCommand {
11
11
  path: Args.string({ description: 'Path to ACF directory (overrides project config)' }),
12
12
  };
13
13
  static description = 'Push ACF field groups, post types, taxonomies, and options pages to WordPress';
14
+ static enableJsonFlag = true;
14
15
  static examples = ['$ lps acf push', '$ lps acf push --type field-groups'];
15
16
  static flags = {
16
17
  ...PushCommand.dryRunFlag,
@@ -34,9 +35,10 @@ export default class Push extends PushCommand {
34
35
  this.error(`${pluralize(this.failedCount, 'ACF object')} failed to push.`);
35
36
  }
36
37
  if (this.dryRun)
37
- return;
38
+ return { path, status: 'dry-run', types };
38
39
  await this.recordSuccess();
39
40
  this.log('All ACF objects pushed.');
41
+ return { path, status: 'success', types };
40
42
  }
41
43
  async currentRevision(type, key) {
42
44
  try {
@@ -16,7 +16,7 @@ export default class Publish extends Command {
16
16
  path: Args.string({ description: 'Path to api directory (overrides project config)' }),
17
17
  };
18
18
  static description = 'Publish custom API routes to your Loopress account so they can be deployed to other projects. Does not touch any WordPress site.';
19
- static examples = ['$ lps api publish', '$ lps api publish --path ./api'];
19
+ static examples = ['$ lps api publish', '$ lps api publish ./api'];
20
20
  async run() {
21
21
  const { args } = await this.parse(Publish);
22
22
  const { apiProjectId, localConfig, token } = await resolveLinkedProject((message) => this.error(message));
@@ -2,9 +2,9 @@ import { confirm } from '@inquirer/prompts';
2
2
  import { Args } from '@oclif/core';
3
3
  import { Buffer } from 'node:buffer';
4
4
  import { existsSync } from 'node:fs';
5
- import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
5
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
6
6
  import { dirname, join } from 'node:path';
7
- import { APP_CONFIG_FILENAME } from '../../lib/app-manifest.js';
7
+ import { APP_CONFIG_FILENAME, parseAppConfig } from '../../lib/app-manifest.js';
8
8
  import { LoopressCommand } from '../../lib/base.js';
9
9
  import { isInteractive } from '../../lib/interactive.js';
10
10
  import { pluralize } from '../../utils/pluralize.js';
@@ -60,14 +60,27 @@ export default class Pull extends LoopressCommand {
60
60
  }
61
61
  async pullApp(path, name) {
62
62
  const manifest = await this.wp.get(`loopress/v1/apps/${name}/manifest`);
63
- const distDir = join(path, name, 'dist');
63
+ const appDir = join(path, name);
64
+ const configPath = join(appDir, APP_CONFIG_FILENAME);
65
+ // WordPress doesn't know where the app builds to (assetsDir) or whether its entry is explicit:
66
+ // keep what the local config says, only refresh what the server is the source of truth for.
67
+ const local = existsSync(configPath) ? parseAppConfig(await readFile(configPath, 'utf8'), appDir) : {};
68
+ const assetsDir = local.assetsDir ?? 'dist';
69
+ const distDir = join(appDir, assetsDir);
64
70
  for (const file of manifest.files) {
65
71
  const { content } = await this.wp.get(`loopress/v1/apps/${name}/asset?path=${encodeURIComponent(file.path)}`);
66
72
  const target = join(distDir, file.path);
67
73
  await mkdir(dirname(target), { recursive: true });
68
74
  await writeFile(target, Buffer.from(content, 'base64'));
69
75
  }
70
- await writeFile(join(path, name, APP_CONFIG_FILENAME), JSON.stringify({ assetsDir: 'dist', mountSelector: manifest.mountSelector, name: manifest.name, routing: manifest.routing }, null, 2) + '\n');
76
+ await writeFile(configPath, JSON.stringify({
77
+ ...local,
78
+ assetsDir,
79
+ crossOrigin: manifest.crossOrigin ?? undefined, // dropped by JSON.stringify when unset
80
+ mountSelector: manifest.mountSelector,
81
+ name: manifest.name,
82
+ routing: manifest.routing,
83
+ }, null, 2) + '\n');
71
84
  this.log(` ${name}: ${pluralize(manifest.files.length, 'file')} (build ${manifest.buildId})`);
72
85
  return { files: manifest.files.length, name };
73
86
  }
@@ -18,11 +18,14 @@ export default class Push extends PushCommand {
18
18
  static flags: {
19
19
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
20
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
+ build: import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
22
  };
23
+ private build;
22
24
  run(): Promise<PushResult>;
23
25
  private appDirNames;
24
26
  private pushApp;
25
27
  private remoteFiles;
26
28
  private uploadAsset;
29
+ private warnIfCrossOriginIgnored;
27
30
  }
28
31
  export {};
@@ -1,7 +1,8 @@
1
- import { Args } from '@oclif/core';
1
+ import { Args, Flags } from '@oclif/core';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { readdir, readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
+ import { buildApp } from '../../lib/app-build.js';
5
6
  import { APP_CONFIG_FILENAME, diffFiles, loadAppManifest, } from '../../lib/app-manifest.js';
6
7
  import { PushCommand } from '../../lib/push-command.js';
7
8
  import { isNotFoundError } from '../../lib/wp-client.js';
@@ -10,15 +11,22 @@ export default class Push extends PushCommand {
10
11
  static args = {
11
12
  name: Args.string({ description: 'Push only this app (defaults to every app in the directory)' }),
12
13
  };
13
- static description = 'Push built single-page app bundles to WordPress. Each app is an `apps/<name>/` directory holding a loopress.app.json and a built dist/ folder. Only files whose content changed are uploaded.';
14
+ static description = 'Build and push single-page app bundles to WordPress. Each app is an `apps/<name>/` directory holding a loopress.app.json and a dist/ folder. When the app has a package.json with a `build` script, it runs first (use --no-build to skip it). Only files whose content changed are uploaded.';
14
15
  static enableJsonFlag = true;
15
- static examples = ['$ lps app push', '$ lps app push search'];
16
+ static examples = ['$ lps app push', '$ lps app push search', '$ lps app push --no-build'];
16
17
  static flags = {
18
+ build: Flags.boolean({
19
+ allowNo: true,
20
+ default: true,
21
+ description: "Run the app's package.json build script before pushing",
22
+ }),
17
23
  ...PushCommand.dryRunFlag,
18
24
  ...PushCommand.yesFlag,
19
25
  };
26
+ build = true;
20
27
  async run() {
21
- const { args } = await this.parse(Push);
28
+ const { args, flags } = await this.parse(Push);
29
+ this.build = flags.build;
22
30
  const { url } = this.siteConfig;
23
31
  const path = this.resolveAppsPath();
24
32
  this.log(`Pushing apps to ${url}`);
@@ -55,7 +63,7 @@ export default class Push extends PushCommand {
55
63
  .filter((entry) => entry.isDirectory())
56
64
  .map((entry) => entry.name)
57
65
  .filter((name) => existsSync(join(path, name, APP_CONFIG_FILENAME)))
58
- .sort((a, b) => a.localeCompare(b));
66
+ .toSorted((a, b) => a.localeCompare(b));
59
67
  if (only) {
60
68
  if (!names.includes(only)) {
61
69
  this.error(`No app "${only}" in ${path} (looked for ${join(only, APP_CONFIG_FILENAME)}).`);
@@ -71,6 +79,13 @@ export default class Push extends PushCommand {
71
79
  let manifest;
72
80
  let distDir;
73
81
  try {
82
+ // Built on dry runs too: the preview diffs dist/, so a stale one would make it lie.
83
+ if (this.build) {
84
+ await buildApp(appDir, (command) => {
85
+ if (task)
86
+ task.output = `${dirName}: building (${command})`;
87
+ });
88
+ }
74
89
  ;
75
90
  ({ distDir, manifest } = await loadAppManifest(appDir, dirName));
76
91
  }
@@ -93,12 +108,14 @@ export default class Push extends PushCommand {
93
108
  task.output = `${manifest.name}: uploading ${file.path} (${index + 1}/${changed.length})`;
94
109
  await this.uploadAsset(manifest.name, distDir, file);
95
110
  }
111
+ let committed;
96
112
  try {
97
- await this.wp.post(`loopress/v1/apps/${manifest.name}/commit`, manifest);
113
+ committed = await this.wp.post(`loopress/v1/apps/${manifest.name}/commit`, manifest);
98
114
  }
99
115
  catch (error) {
100
116
  this.reportTaskFailure(`${manifest.name}: commit failed: ${error.message}`, error, task);
101
117
  }
118
+ this.warnIfCrossOriginIgnored(manifest, committed);
102
119
  if (task) {
103
120
  task.output =
104
121
  changed.length === 0
@@ -127,4 +144,11 @@ export default class Push extends PushCommand {
127
144
  this.reportTaskFailure(`${name}: failed to upload ${file.path}: ${error.message}`, error);
128
145
  }
129
146
  }
147
+ // An older plugin, or an app .htaccess the site owner manages, commits the build without the
148
+ // policy: say so rather than let the app look protected when it is not.
149
+ warnIfCrossOriginIgnored(manifest, committed) {
150
+ if (manifest.crossOrigin && committed?.crossOrigin !== manifest.crossOrigin) {
151
+ this.warn(`${manifest.name}: crossOrigin "${manifest.crossOrigin}" was not applied: either the Loopress plugin on this site is too old (update it, then push again) or wp-content/loopress/apps/${manifest.name}/.htaccess is managed by hand and was left untouched.`);
152
+ }
153
+ }
130
154
  }
@@ -3,27 +3,9 @@ import { existsSync } from 'node:fs';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { PushCommand } from '../../lib/push-command.js';
6
- import { isTimeoutError } from '../../lib/wp-client.js';
6
+ import { isLocalEnvironment, isTimeoutError } from '../../lib/wp-client.js';
7
+ import { requireToInstall, toIntent } from '../../utils/composer.js';
7
8
  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
- }
27
9
  export default class ComposerPush extends PushCommand {
28
10
  static description = 'Push composer.json to WordPress and run Composer to resolve and install dependencies';
29
11
  static enableJsonFlag = true;
@@ -43,7 +25,7 @@ export default class ComposerPush extends PushCommand {
43
25
  this.error(`No composer.json found at ${composerJsonPath}. Run \`lps composer init\` first.`);
44
26
  }
45
27
  const parsed = JSON.parse(await readFile(composerJsonPath, 'utf8'));
46
- const require = parsed.require ?? {};
28
+ const require = requireToInstall(parsed, isLocalEnvironment(this.siteConfig.name));
47
29
  const packageCount = Object.keys(require).length;
48
30
  const intent = toIntent(require);
49
31
  const hasLock = existsSync(composerLockPath);
@@ -7,7 +7,7 @@ const RESOURCES = [...RESOURCE_STATE_PROVIDERS.map((provider) => provider.resour
7
7
  export default class Diff extends DiffCommand {
8
8
  static description = [
9
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, pages, SEO, Options, Menus, Theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
10
+ 'Covers snippets, forms, ACF, API routes, Hooks, templates, template parts, pages, SEO, Options, Menus, Theme styles, and Composer. Plugins and themes have their own `lps plugin status` / `lps theme status`.',
11
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
12
  ].join(' ');
13
13
  static enableJsonFlag = true;
@@ -1,13 +1,21 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ type FormPullResult = {
3
+ orphans: string[];
4
+ path: string;
5
+ skipped: number;
6
+ status: 'dry-run' | 'success';
7
+ };
2
8
  export default class Pull extends LoopressCommand {
3
9
  static args: {
4
10
  path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
11
  };
6
12
  static description: string;
13
+ static enableJsonFlag: boolean;
7
14
  static examples: string[];
8
15
  static flags: {
9
16
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
17
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
18
  };
12
- run(): Promise<void>;
19
+ run(): Promise<FormPullResult>;
13
20
  }
21
+ export {};
@@ -11,6 +11,7 @@ export default class Pull extends LoopressCommand {
11
11
  path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
12
12
  };
13
13
  static description = 'Pull forms from WordPress';
14
+ static enableJsonFlag = true;
14
15
  static examples = ['$ lps form pull'];
15
16
  static flags = {
16
17
  ...LoopressCommand.dryRunFlag,
@@ -46,9 +47,10 @@ export default class Pull extends LoopressCommand {
46
47
  },
47
48
  });
48
49
  if (this.dryRun)
49
- return;
50
+ return { orphans, path, skipped, status: 'dry-run' };
50
51
  if (skipped > 0) {
51
52
  this.warn(`${pluralize(skipped, 'form')} skipped because they have no id`);
52
53
  }
54
+ return { orphans, path, skipped, status: 'success' };
53
55
  }
54
56
  }
@@ -1,9 +1,14 @@
1
1
  import { PushCommand } from '../../lib/push-command.js';
2
+ type FormPushResult = {
3
+ path: string;
4
+ status: 'dry-run' | 'success';
5
+ };
2
6
  export default class Push extends PushCommand {
3
7
  static args: {
4
8
  path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
9
  };
6
10
  static description: string;
11
+ static enableJsonFlag: boolean;
7
12
  static examples: string[];
8
13
  static flags: {
9
14
  'allow-notifications': import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -11,9 +16,10 @@ export default class Push extends PushCommand {
11
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
17
  };
13
18
  private allowNotifications;
14
- run(): Promise<void>;
19
+ run(): Promise<FormPushResult>;
15
20
  private currentRevision;
16
21
  private ensureCanonicalFilename;
17
22
  private loadFiles;
18
23
  private pushForm;
19
24
  }
25
+ export {};
@@ -14,6 +14,7 @@ export default class Push extends PushCommand {
14
14
  path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
15
15
  };
16
16
  static description = 'Push forms to WordPress. Local files created or updated remotely are renamed on disk to the `<id>-<slug>.json` convention.';
17
+ static enableJsonFlag = true;
17
18
  static examples = ['$ lps form push', '$ lps form push --allow-notifications'];
18
19
  static flags = {
19
20
  ...PushCommand.dryRunFlag,
@@ -41,9 +42,10 @@ export default class Push extends PushCommand {
41
42
  this.error(`${pluralize(this.failedCount, 'form')} failed to push.`);
42
43
  }
43
44
  if (this.dryRun)
44
- return;
45
+ return { path, status: 'dry-run' };
45
46
  await this.recordSuccess();
46
47
  this.log('All forms pushed.');
48
+ return { path, status: 'success' };
47
49
  }
48
50
  async currentRevision(id) {
49
51
  try {
@@ -1,4 +1,4 @@
1
- import { checkbox, confirm, input, select } from '@inquirer/prompts';
1
+ import { confirm, input, select } from '@inquirer/prompts';
2
2
  import { Command } from '@oclif/core';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
@@ -11,17 +11,6 @@ const SNIPPET_PROVIDERS = [
11
11
  { name: 'Code Snippets', slug: 'code-snippets' },
12
12
  { name: 'WPCode', slug: 'insert-headers-and-footers' },
13
13
  ];
14
- // Default directory per optional feature, matching the resolve*Path defaults in lib/base.ts.
15
- const FEATURES = [
16
- { dir: 'acf', key: 'acfDir', label: 'ACF' },
17
- { dir: 'seo', key: 'seoDir', label: 'SEO' },
18
- { dir: 'menus', key: 'menuDir', label: 'Menus' },
19
- { dir: 'forms', key: 'formDir', label: 'Forms' },
20
- { dir: 'api', key: 'apiDir', label: 'Custom API routes' },
21
- { dir: 'hooks', key: 'hooksDir', label: 'Hooks (actions, filters, cron)' },
22
- { dir: 'apps', key: 'appsDir', label: 'Single-page apps' },
23
- { dir: 'pages', key: 'pageDir', label: 'Static pages' },
24
- ];
25
14
  export default class Init extends Command {
26
15
  static description = 'Initialize a loopress.json config file in the current directory';
27
16
  static examples = ['$ lps init'];
@@ -46,27 +35,13 @@ export default class Init extends Command {
46
35
  default: '.',
47
36
  message: 'Root directory',
48
37
  });
49
- const snippetsDir = await input({
50
- default: 'snippets',
51
- message: 'Snippets directory (relative to root)',
52
- });
53
- const features = await checkbox({
54
- choices: FEATURES.map((feature) => ({ name: `${feature.label} (${feature.dir}/)`, value: feature.key })),
55
- message: 'Other features to configure directories for (optional)',
56
- });
57
38
  const providerChoice = await select({
58
39
  choices: [...SNIPPET_PROVIDERS.map((p) => ({ name: p.name, value: p.slug })), { name: 'None / already installed', value: '__none__' }],
59
40
  message: 'Snippet provider',
60
41
  });
61
- const config = {
62
- projectId,
63
- rootDir,
64
- snippetsDir,
65
- };
66
- for (const feature of FEATURES) {
67
- if (features.includes(feature.key))
68
- config[feature.key] = feature.dir;
69
- }
42
+ // Resource directories are left out on purpose: every command falls back to its default
43
+ // (utils/resource-dirs.ts), and a custom name is a one-line edit in loopress.json.
44
+ const config = { projectId, rootDir };
70
45
  await writeLocalConfig(config);
71
46
  const isGitattributesUpdated = await ensureLfGitattributes();
72
47
  let isProviderAdded = false;
@@ -81,19 +56,14 @@ export default class Init extends Command {
81
56
  }
82
57
  this.log(`\n✓ loopress.json created`);
83
58
  this.log(` Project: ${projectLabel}`);
84
- this.log(` Snippets: ${join(rootDir, snippetsDir)}`);
85
- for (const feature of FEATURES) {
86
- if (features.includes(feature.key)) {
87
- this.log(` ${feature.label}:${' '.repeat(Math.max(1, 9 - feature.label.length))}${join(rootDir, feature.dir)}`);
88
- }
89
- }
59
+ this.log(` Root: ${rootDir}`);
90
60
  if (isProviderAdded) {
91
61
  this.log(` Plugin: ${providerChoice}`);
92
62
  }
93
63
  if (isGitattributesUpdated) {
94
64
  this.log('✓ .gitattributes: LF line endings, so files match across macOS, Linux and Windows');
95
65
  }
96
- this.log('\n→ Next: run `lps snippet pull` to fetch what is already on the site, or `lps doctor` to verify the connection.');
66
+ this.log('\n→ Next: run `lps pull` to fetch what is already on the site, or `lps doctor` to verify the connection.');
97
67
  }
98
68
  // When nothing is configured yet, the useful path is configuring a project right here, not
99
69
  // typing a project ID that points at nothing. Manual entry stays available as an explicit
@@ -1,15 +1,21 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ type MenuPullResult = {
3
+ path: string;
4
+ status: 'dry-run' | 'success';
5
+ };
2
6
  export default class Pull extends LoopressCommand {
3
7
  static args: {
4
8
  path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
9
  };
6
10
  static description: string;
11
+ static enableJsonFlag: boolean;
7
12
  static examples: string[];
8
13
  static flags: {
9
14
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
15
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
16
  };
12
- run(): Promise<void>;
17
+ run(): Promise<MenuPullResult>;
13
18
  private pullLocations;
14
19
  private pullMenus;
15
20
  }
21
+ export {};
@@ -12,6 +12,7 @@ export default class Pull extends LoopressCommand {
12
12
  path: Args.string({ description: 'Path to menus directory (overrides project config)' }),
13
13
  };
14
14
  static description = 'Pull nav menus and the active theme menu locations from WordPress';
15
+ static enableJsonFlag = true;
15
16
  static examples = ['$ lps menu pull'];
16
17
  static flags = {
17
18
  ...LoopressCommand.dryRunFlag,
@@ -25,6 +26,7 @@ export default class Pull extends LoopressCommand {
25
26
  this.log(`Menus path: ${path}`);
26
27
  await this.pullMenus(path);
27
28
  await this.pullLocations(path);
29
+ return { path, status: this.dryRun ? 'dry-run' : 'success' };
28
30
  }
29
31
  async pullLocations(basePath) {
30
32
  const file = join(basePath, `${LOCATIONS_FILE_BASENAME}.json`);
@@ -1,17 +1,23 @@
1
1
  import { PushCommand } from '../../lib/push-command.js';
2
+ type MenuPushResult = {
3
+ path: string;
4
+ status: 'dry-run' | 'success';
5
+ };
2
6
  export default class Push extends PushCommand {
3
7
  static args: {
4
8
  path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
9
  };
6
10
  static description: string;
11
+ static enableJsonFlag: boolean;
7
12
  static examples: string[];
8
13
  static flags: {
9
14
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
15
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
16
  };
12
- run(): Promise<void>;
17
+ run(): Promise<MenuPushResult>;
13
18
  private currentRevision;
14
19
  private pushLocations;
15
20
  private pushMenuFile;
16
21
  private pushMenus;
17
22
  }
23
+ export {};
@@ -15,6 +15,7 @@ export default class Push extends PushCommand {
15
15
  path: Args.string({ description: 'Path to menus directory (overrides project config)' }),
16
16
  };
17
17
  static description = 'Push local nav menus and the active theme menu locations to WordPress. Each menu\'s post_type/taxonomy items are resolved by slug on the target environment, never by a raw id; an item whose target does not exist there fails the whole menu rather than guessing.';
18
+ static enableJsonFlag = true;
18
19
  static examples = ['$ lps menu push'];
19
20
  static flags = {
20
21
  ...PushCommand.dryRunFlag,
@@ -35,9 +36,10 @@ export default class Push extends PushCommand {
35
36
  this.error(`${pluralize(this.failedCount, 'menu')} failed to push.`);
36
37
  }
37
38
  if (this.dryRun)
38
- return;
39
+ return { path, status: 'dry-run' };
39
40
  await this.recordSuccess();
40
41
  this.log('All nav menus pushed.');
42
+ return { path, status: 'success' };
41
43
  }
42
44
  async currentRevision(slug) {
43
45
  try {
@@ -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);
@@ -1,5 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { LoopressCommand } from '../../lib/base.js';
3
+ import { pinVersion } from '../../types/plugin.js';
3
4
  import { writeLocalConfig } from '../../utils/loopress-config.js';
4
5
  import { isExactVersion } from '../../utils/version.js';
5
6
  export default class Add extends LoopressCommand {
@@ -25,18 +26,20 @@ export default class Add extends LoopressCommand {
25
26
  this.error(`--version must be an exact version like 9.4.2, not a Composer constraint. Got "${version}".`);
26
27
  }
27
28
  const existing = this.localConfig.plugins ?? {};
28
- if (existing[slug] === version) {
29
+ const current = existing[slug];
30
+ if (current !== undefined && pinVersion(current) === version) {
29
31
  this.log(`${slug} is already pinned to ${version} in loopress.json, nothing to do.`);
30
32
  return { slug, status: 'unchanged', version };
31
33
  }
32
- const isUpdated = existing[slug] !== undefined;
34
+ const isUpdated = current !== undefined;
33
35
  if (this.dryRun) {
34
36
  this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version}) in loopress.json`);
35
37
  return { slug, status: 'dry-run', version };
36
38
  }
37
39
  await writeLocalConfig({
38
40
  ...this.localConfig,
39
- plugins: { ...existing, [slug]: version },
41
+ // Re-pinning a plugin recorded as inactive keeps it inactive.
42
+ plugins: { ...existing, [slug]: typeof current === 'object' ? { ...current, version } : version },
40
43
  });
41
44
  this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version}). Run \`lps plugin push\` to apply.`);
42
45
  return { slug, status: isUpdated ? 'updated' : 'added', version };