@loopress/cli 0.23.0 → 0.24.1

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 (101) hide show
  1. package/README.md +603 -94
  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/api/rm.d.ts +2 -0
  13. package/dist/commands/api/rm.js +2 -0
  14. package/dist/commands/composer/pull.js +6 -16
  15. package/dist/commands/composer/push.d.ts +3 -0
  16. package/dist/commands/composer/push.js +24 -7
  17. package/dist/commands/dev.js +1 -1
  18. package/dist/commands/diff.d.ts +13 -0
  19. package/dist/commands/diff.js +42 -0
  20. package/dist/commands/form/diff.d.ts +2 -0
  21. package/dist/commands/form/diff.js +5 -0
  22. package/dist/commands/form/push.d.ts +2 -0
  23. package/dist/commands/form/push.js +11 -4
  24. package/dist/commands/hook/diff.d.ts +2 -0
  25. package/dist/commands/hook/diff.js +5 -0
  26. package/dist/commands/hook/list.d.ts +2 -0
  27. package/dist/commands/hook/list.js +2 -0
  28. package/dist/commands/hook/pull.d.ts +2 -0
  29. package/dist/commands/hook/pull.js +2 -0
  30. package/dist/commands/hook/push.d.ts +2 -0
  31. package/dist/commands/hook/push.js +2 -0
  32. package/dist/commands/hook/rm.d.ts +2 -0
  33. package/dist/commands/hook/rm.js +2 -0
  34. package/dist/commands/init.js +1 -1
  35. package/dist/commands/login.js +3 -2
  36. package/dist/commands/option/add.d.ts +15 -0
  37. package/dist/commands/option/add.js +47 -0
  38. package/dist/commands/option/diff.d.ts +2 -0
  39. package/dist/commands/option/diff.js +5 -0
  40. package/dist/commands/{page → option}/list.d.ts +5 -1
  41. package/dist/commands/option/list.js +151 -0
  42. package/dist/commands/{page → option}/pull.d.ts +3 -12
  43. package/dist/commands/option/pull.js +75 -0
  44. package/dist/commands/{page → option}/push.d.ts +3 -8
  45. package/dist/commands/option/push.js +66 -0
  46. package/dist/commands/option/remove.d.ts +22 -0
  47. package/dist/commands/option/remove.js +69 -0
  48. package/dist/commands/plugin/push.d.ts +0 -1
  49. package/dist/commands/plugin/push.js +3 -10
  50. package/dist/commands/pull.js +3 -2
  51. package/dist/commands/push.js +4 -3
  52. package/dist/commands/seo/diff.d.ts +2 -0
  53. package/dist/commands/seo/diff.js +5 -0
  54. package/dist/commands/seo/push.d.ts +2 -0
  55. package/dist/commands/seo/push.js +16 -4
  56. package/dist/commands/snippet/diff.d.ts +2 -0
  57. package/dist/commands/snippet/diff.js +5 -0
  58. package/dist/commands/theme/push.d.ts +0 -1
  59. package/dist/commands/theme/push.js +3 -10
  60. package/dist/hooks/finally.js +4 -1
  61. package/dist/lib/base.d.ts +2 -1
  62. package/dist/lib/base.js +12 -22
  63. package/dist/lib/dev-watch.d.ts +1 -1
  64. package/dist/lib/dev-watch.js +1 -2
  65. package/dist/lib/diff-command.d.ts +47 -0
  66. package/dist/lib/diff-command.js +180 -0
  67. package/dist/lib/diff-state.d.ts +22 -0
  68. package/dist/lib/diff-state.js +88 -0
  69. package/dist/lib/find-orphaned-files.js +1 -1
  70. package/dist/lib/interactive.d.ts +1 -0
  71. package/dist/lib/interactive.js +10 -0
  72. package/dist/lib/local-callback-server.d.ts +14 -1
  73. package/dist/lib/local-callback-server.js +40 -1
  74. package/dist/lib/php-files-command.d.ts +67 -0
  75. package/dist/lib/php-files-command.js +338 -0
  76. package/dist/lib/put-or-create.js +1 -1
  77. package/dist/lib/readdir-tolerant.d.ts +1 -0
  78. package/dist/lib/readdir-tolerant.js +3 -1
  79. package/dist/lib/resource-state.d.ts +14 -0
  80. package/dist/lib/resource-state.js +361 -0
  81. package/dist/lib/sentry.d.ts +3 -0
  82. package/dist/lib/sentry.js +30 -0
  83. package/dist/lib/validate-local.js +15 -7
  84. package/dist/lib/wp-authorize-flow.d.ts +4 -0
  85. package/dist/lib/wp-authorize-flow.js +10 -8
  86. package/dist/lib/wp-client.d.ts +5 -0
  87. package/dist/lib/wp-client.js +56 -0
  88. package/dist/types/project-config.generated.d.ts +8 -4
  89. package/dist/utils/option-format.d.ts +31 -0
  90. package/dist/utils/option-format.js +69 -0
  91. package/dist/utils/plugin-sync.d.ts +6 -0
  92. package/dist/utils/resource-dirs.d.ts +13 -0
  93. package/dist/utils/resource-dirs.js +34 -0
  94. package/oclif.manifest.json +1488 -301
  95. package/package.json +11 -8
  96. package/schemas/project-config.schema.json +10 -5
  97. package/dist/commands/page/list.js +0 -19
  98. package/dist/commands/page/pull.js +0 -57
  99. package/dist/commands/page/push.js +0 -132
  100. package/dist/utils/page-format.d.ts +0 -7
  101. package/dist/utils/page-format.js +0 -78
@@ -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('acf', {
3
+ description: 'Show what differs in ACF field groups, post types, taxonomies, and options pages between your local files and a WordPress environment, or between two environments',
4
+ pathNoun: 'ACF 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('api', {
3
+ description: 'Show what differs in custom API route files between your local files and a WordPress environment, or between two environments',
4
+ pathNoun: 'api directory',
5
+ });
@@ -1,12 +1,2 @@
1
- import { LoopressCommand } from '../../lib/base.js';
2
- type ApiFile = {
3
- content: string;
4
- filename: string;
5
- };
6
- export default class List extends LoopressCommand {
7
- static description: string;
8
- static enableJsonFlag: boolean;
9
- static examples: string[];
10
- run(): Promise<ApiFile[]>;
11
- }
12
- export {};
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").ListFilesCommand>;
2
+ export default _default;
@@ -1,20 +1,2 @@
1
- import { LoopressCommand } from '../../lib/base.js';
2
- import { pluralize } from '../../utils/pluralize.js';
3
- export default class List extends LoopressCommand {
4
- static description = 'List custom API route files from WordPress';
5
- static enableJsonFlag = true;
6
- static examples = ['$ lps api list'];
7
- async run() {
8
- const files = await this.wp.get('loopress/v1/api-files');
9
- if (files.length === 0) {
10
- this.log('No API route files found');
11
- return files;
12
- }
13
- this.log(`Found ${pluralize(files.length, 'route file')}:`);
14
- this.log('');
15
- for (const file of files) {
16
- this.log(` ${file.filename}`);
17
- }
18
- return files;
19
- }
20
- }
1
+ import { API_FILES_RESOURCE, resourceListCommand } from '../../lib/php-files-command.js';
2
+ export default resourceListCommand(API_FILES_RESOURCE);
@@ -1,20 +1,2 @@
1
- import { LoopressCommand } from '../../lib/base.js';
2
- type PullResult = {
3
- orphans: string[];
4
- pulled: string[];
5
- status: 'dry-run' | 'success';
6
- };
7
- export default class Pull extends LoopressCommand {
8
- static args: {
9
- path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
10
- };
11
- static description: string;
12
- static enableJsonFlag: boolean;
13
- static examples: string[];
14
- static flags: {
15
- yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
- 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
- };
18
- run(): Promise<PullResult>;
19
- }
20
- export {};
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").PullFilesCommand>;
2
+ export default _default;
@@ -1,53 +1,2 @@
1
- import { Args } from '@oclif/core';
2
- import { mkdir, writeFile } from 'node:fs/promises';
3
- import { dirname, join } from 'node:path';
4
- import { LoopressCommand } from '../../lib/base.js';
5
- import { basenameKey, findOrphanedFiles } from '../../lib/find-orphaned-files.js';
6
- import { pluralize } from '../../utils/pluralize.js';
7
- export default class Pull extends LoopressCommand {
8
- static args = {
9
- path: Args.string({ description: 'Path to api directory (overrides project config)' }),
10
- };
11
- static description = 'Pull custom API route files from WordPress';
12
- static enableJsonFlag = true;
13
- static examples = ['$ lps api pull', '$ lps api pull --path ./api'];
14
- static flags = {
15
- ...LoopressCommand.dryRunFlag,
16
- ...LoopressCommand.yesFlag,
17
- };
18
- async run() {
19
- const { args } = await this.parse(Pull);
20
- const { url } = this.siteConfig;
21
- const path = this.resolveApiPath(args.path);
22
- this.log(`Pulling API routes from ${url}`);
23
- this.log(`API path: ${path}`);
24
- const files = await this.wp.get('loopress/v1/api-files');
25
- // A `<filename>.php` no longer present remotely belongs to a route deleted on WordPress
26
- // (push itself stays additive-only, but pull already cleans up locally, same as
27
- // `snippet pull`).
28
- const orphans = await findOrphanedFiles(path, new Set(files.map((file) => file.filename)), {
29
- extensions: ['.php'],
30
- key: basenameKey,
31
- recursive: true,
32
- });
33
- const pulled = files.map((file) => file.filename);
34
- await this.pullDirectory(path, files, orphans, {
35
- alwaysCreateDir: true,
36
- dryRunMessage: `Would pull ${pluralize(files.length, 'route file')} to ${path}`,
37
- orphanReason: 'whose route no longer exists on WordPress',
38
- pulledMessage: `Pulled ${pluralize(files.length, 'route file')} to ${path}`,
39
- title: (file) => file.filename,
40
- async write(file, writeDir) {
41
- const filePath = join(writeDir, `${file.filename}.php`);
42
- // filename can contain '/' (a path-param route, e.g. invoice-pdf/[order_id]):
43
- // writeFile() doesn't create parent directories on its own the way mkdir()'s
44
- // recursive option does above for the top-level path.
45
- await mkdir(dirname(filePath), { recursive: true });
46
- await writeFile(filePath, file.content);
47
- },
48
- });
49
- if (this.dryRun)
50
- return { orphans, pulled, status: 'dry-run' };
51
- return { orphans, pulled, status: 'success' };
52
- }
53
- }
1
+ import { API_FILES_RESOURCE, resourcePullCommand } from '../../lib/php-files-command.js';
2
+ export default resourcePullCommand(API_FILES_RESOURCE);
@@ -1,22 +1,2 @@
1
- import { PushCommand } from '../../lib/push-command.js';
2
- type PushResult = {
3
- pushed: string[];
4
- status: 'dry-run' | 'success';
5
- };
6
- export default class Push extends PushCommand {
7
- static args: {
8
- path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
9
- };
10
- static description: string;
11
- static enableJsonFlag: boolean;
12
- static examples: string[];
13
- static flags: {
14
- yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
- 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
- };
17
- run(): Promise<PushResult>;
18
- private loadFiles;
19
- private pushFile;
20
- private syncApiRoutes;
21
- }
22
- export {};
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").PushFilesCommand>;
2
+ export default _default;
@@ -1,116 +1,2 @@
1
- import { Args } from '@oclif/core';
2
- import { relative, sep } from 'node:path';
3
- import { authManager } from '../../config/auth.manager.js';
4
- import { ApiClient } from '../../lib/api-client.js';
5
- import { loadFiles as loadDirectoryFiles } from '../../lib/load-files.js';
6
- import { PushCommand } from '../../lib/push-command.js';
7
- import { pluralize } from '../../utils/pluralize.js';
8
- // Mirrors the server's own allowlist (wordpress-plugin ApiFilesController::isValidFilename()):
9
- // filename is sent as a body param (not a URL path segment: a nested path can contain '/' and
10
- // '[]', and depending on a server to handle a percent-encoded slash in a URL path correctly is
11
- // exactly the kind of hosting-environment variance Loopress can't assume away), but validating
12
- // client-side first still turns a malformed name into an accurate error and skips a network
13
- // round-trip that could only ever fail.
14
- const FILENAME_PATTERN = /^(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/;
15
- // Mirrors the server's own check (wordpress-plugin FileWriter::DECLARE_PATTERN /
16
- // withGuard()): the server rejects both an absent declare(strict_types=1); and one that
17
- // appears more than once (it needs a single unambiguous insertion point for the ABSPATH
18
- // guard). Matching that "exactly once" rule here too, not just presence, so a file that
19
- // would still fail server-side doesn't falsely pass this earlier check.
20
- const DECLARE_PATTERN = /declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;/g;
21
- export default class Push extends PushCommand {
22
- static args = {
23
- path: Args.string({ description: 'Path to api directory (overrides project config)' }),
24
- };
25
- static description = 'Push custom API route files to WordPress';
26
- static enableJsonFlag = true;
27
- static examples = ['$ lps api push', '$ lps api push --path ./api'];
28
- static flags = {
29
- ...PushCommand.dryRunFlag,
30
- ...PushCommand.yesFlag,
31
- };
32
- async run() {
33
- const { args } = await this.parse(Push);
34
- const { url } = this.siteConfig;
35
- const path = this.resolveApiPath(args.path);
36
- this.log(`Pushing API routes to ${url}`);
37
- this.log(`API path: ${path}`);
38
- const files = await this.loadFiles(path);
39
- this.log(`Found ${pluralize(files.length, 'route file')} to push`);
40
- const pushed = [];
41
- await this.runPushTasks(files, (file) => file.filename, async (file, task) => {
42
- await this.pushFile(file, task);
43
- pushed.push(file.filename);
44
- });
45
- if (this.failedCount > 0) {
46
- this.error(`${pluralize(this.failedCount, 'route file')} failed to push.`);
47
- }
48
- if (this.dryRun)
49
- return { pushed, status: 'dry-run' };
50
- await this.recordSuccess();
51
- await this.syncApiRoutes(files.map((file) => file.filename));
52
- this.log('All API routes pushed.');
53
- return { pushed, status: 'success' };
54
- }
55
- async loadFiles(path) {
56
- return loadDirectoryFiles(path, {
57
- extension: '.php',
58
- onSkip: (message) => { this.warn(message); },
59
- // relative()'s separator is OS-specific ('\\' on Windows); the server only ever expects
60
- // '/', same as any URL or import path.
61
- parse: (raw, filePath) => ({
62
- content: raw,
63
- filename: relative(path, filePath).slice(0, -'.php'.length).split(sep).join('/'),
64
- }),
65
- recursive: true,
66
- });
67
- }
68
- async pushFile(file, task) {
69
- if (!FILENAME_PATTERN.test(file.filename)) {
70
- const message = `Invalid filename "${file.filename}": each path segment must be lowercase letters, digits, and hyphens, or a bracketed dynamic segment like "[order_id]" (e.g. "invoice-pdf/[order_id].php")`;
71
- this.reportTaskFailure(message, new Error(message), task);
72
- }
73
- const declareCount = file.content.match(DECLARE_PATTERN)?.length ?? 0;
74
- if (declareCount !== 1) {
75
- const reason = declareCount === 0 ? 'is missing' : 'appears more than once';
76
- const message = `${file.filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`;
77
- this.reportTaskFailure(message, new Error(message), task);
78
- }
79
- if (this.dryRun) {
80
- if (task)
81
- task.output = `[dry-run] Would push: ${file.filename}`;
82
- return;
83
- }
84
- try {
85
- const result = await this.wp.put('loopress/v1/api-files', {
86
- content: file.content,
87
- filename: file.filename,
88
- });
89
- if (task) {
90
- task.output =
91
- result.syntax_check === 'skipped'
92
- ? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
93
- : `Pushed: ${file.filename}`;
94
- }
95
- }
96
- catch (error) {
97
- this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
98
- }
99
- }
100
- // Best-effort report of the current file list to the Loopress cloud, purely for console
101
- // visibility (see obsidian/Product/API and Console Backlog.md, US-18): the routes are already
102
- // live on WordPress by this point, this can never block or fail the push itself, same
103
- // reasoning as `recordDeployment` in PushCommand.
104
- async syncApiRoutes(filenames) {
105
- const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token;
106
- const environmentId = this.siteConfig.apiEnvironmentId;
107
- if (!token || !environmentId)
108
- return;
109
- try {
110
- await new ApiClient(token).put('api-routes', { environmentId, filenames });
111
- }
112
- catch {
113
- // non-blocking: reporting the route list must never interrupt the push flow
114
- }
115
- }
116
- }
1
+ import { API_FILES_RESOURCE, resourcePushCommand } from '../../lib/php-files-command.js';
2
+ export default resourcePushCommand(API_FILES_RESOURCE);
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").RmFilesCommand>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { API_FILES_RESOURCE, resourceRmCommand } from '../../lib/php-files-command.js';
2
+ export default resourceRmCommand(API_FILES_RESOURCE);
@@ -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';
@@ -1,6 +1,8 @@
1
1
  import { PushCommand } from '../../lib/push-command.js';
2
+ import { type LockDriftEntry } from '../../utils/plugin-sync.js';
2
3
  type PushResult = {
3
4
  hasLock: boolean;
5
+ lockDrift: LockDriftEntry[];
4
6
  packageCount: number;
5
7
  status: 'dry-run' | 'success';
6
8
  };
@@ -14,5 +16,6 @@ export default class ComposerPush extends PushCommand {
14
16
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
17
  };
16
18
  run(): Promise<PushResult>;
19
+ private reportLockDrift;
17
20
  }
18
21
  export {};
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { PushCommand } from '../../lib/push-command.js';
6
6
  import { isTimeoutError } from '../../lib/wp-client.js';
7
- import { parseCollisions, SYNC_TIMEOUT_MS } from '../../utils/plugin-sync.js';
7
+ import { parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
8
8
  // Split a composer.json `require` map into the three intent namespaces the sync endpoint
9
9
  // understands. `composer/installers` is owned by the server scaffold, never sent.
10
10
  function toIntent(require) {
@@ -25,7 +25,7 @@ function toIntent(require) {
25
25
  return intent;
26
26
  }
27
27
  export default class ComposerPush extends PushCommand {
28
- static description = 'Push composer.json (and composer.lock, if present) to WordPress and run Composer';
28
+ static description = 'Push composer.json to WordPress and run Composer to resolve and install dependencies';
29
29
  static enableJsonFlag = true;
30
30
  static examples = ['$ lps composer push', '$ lps composer push --dry-run'];
31
31
  static flags = {
@@ -47,14 +47,16 @@ export default class ComposerPush extends PushCommand {
47
47
  const packageCount = Object.keys(require).length;
48
48
  const intent = toIntent(require);
49
49
  const hasLock = existsSync(composerLockPath);
50
+ // The server resolves composer.json itself; it never installs from this lock (a crafted
51
+ // lock could point package downloads at arbitrary hosts). It is sent only so the server
52
+ // can report which pinned versions its own resolution moved. `lps composer pull` brings
53
+ // the resolved lock back.
50
54
  const lock = hasLock ? await readFile(composerLockPath, 'utf8') : null;
51
55
  this.log(`Pushing composer.json (${packageCount} ${packageCount === 1 ? 'package' : 'packages'}) to ${url}`);
52
56
  if (lock)
53
- this.log(' + composer.lock included (reproducible install)');
54
- else
55
- this.warn('No composer.lock found. The server will resolve versions freely.');
57
+ this.log(' + composer.lock sent for drift comparison (the server resolves versions from composer.json)');
56
58
  if (this.dryRun)
57
- return { hasLock, packageCount, status: 'dry-run' };
59
+ return { hasLock, lockDrift: [], packageCount, status: 'dry-run' };
58
60
  this.log('Running Composer on the server, this can take a few minutes...');
59
61
  let response;
60
62
  try {
@@ -72,7 +74,22 @@ export default class ComposerPush extends PushCommand {
72
74
  if (response.output.trim())
73
75
  this.log(response.output.trim());
74
76
  this.log('Composer run completed on the server.');
77
+ const lockDrift = response.lockDrift ?? [];
78
+ this.reportLockDrift(lockDrift);
75
79
  await this.recordSuccess();
76
- return { hasLock, packageCount, status: 'success' };
80
+ return { hasLock, lockDrift, packageCount, status: 'success' };
81
+ }
82
+ // The server resolves composer.json from scratch and never installs from the uploaded lock,
83
+ // so a project that committed a lock can see versions move. Spell out exactly what changed
84
+ // and point at `lps composer pull` to bring the resolved lock back locally.
85
+ reportLockDrift(lockDrift) {
86
+ if (lockDrift.length === 0)
87
+ return;
88
+ this.log('');
89
+ this.log(`The server resolved ${lockDrift.length} ${lockDrift.length === 1 ? 'package' : 'packages'} to a different version than your local composer.lock:`);
90
+ for (const { from, name, to } of lockDrift) {
91
+ this.log(` ${name}: ${from ?? '(absent)'} -> ${to ?? '(removed)'}`);
92
+ }
93
+ this.log('Run `lps composer pull` to update your local composer.json and composer.lock.');
77
94
  }
78
95
  }
@@ -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
+ });
@@ -6,9 +6,11 @@ export default class Push extends PushCommand {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ 'allow-notifications': import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
12
  };
13
+ private allowNotifications;
12
14
  run(): Promise<void>;
13
15
  private ensureCanonicalFilename;
14
16
  private loadFiles;
@@ -1,4 +1,4 @@
1
- import { Args } from '@oclif/core';
1
+ import { Args, Flags } from '@oclif/core';
2
2
  import { readFile, rename } from 'node:fs/promises';
3
3
  import { dirname, extname, join } from 'node:path';
4
4
  import { PushCommand } from '../../lib/push-command.js';
@@ -12,15 +12,21 @@ export default class Push extends PushCommand {
12
12
  path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
13
13
  };
14
14
  static description = 'Push forms to WordPress. Local files created or updated remotely are renamed on disk to the `<id>-<slug>.json` convention.';
15
- static examples = ['$ lps form push'];
15
+ static examples = ['$ lps form push', '$ lps form push --allow-notifications'];
16
16
  static flags = {
17
17
  ...PushCommand.dryRunFlag,
18
18
  ...PushCommand.yesFlag,
19
+ 'allow-notifications': Flags.boolean({
20
+ default: false,
21
+ description: "Also push each form's notification and confirmation settings (recipients, sender, messages). Off by default: the server keeps its own so a stray push can't redirect submissions.",
22
+ }),
19
23
  };
24
+ allowNotifications = false;
20
25
  async run() {
21
- const { args } = await this.parse(Push);
26
+ const { args, flags } = await this.parse(Push);
22
27
  const { url } = this.siteConfig;
23
28
  const path = this.resolveFormPath(args.path);
29
+ this.allowNotifications = flags['allow-notifications'];
24
30
  this.log(`Pushing forms to ${url}`);
25
31
  this.log(`Forms path: ${path}`);
26
32
  const files = await this.loadFiles(path);
@@ -78,9 +84,10 @@ export default class Push extends PushCommand {
78
84
  }
79
85
  try {
80
86
  const id = getFormId(data);
87
+ const payload = this.allowNotifications ? { ...data, allowNotifications: true } : data;
81
88
  const { body, created } = await putOrCreate(this.wp, {
82
89
  id,
83
- payload: data,
90
+ payload,
84
91
  postEndpoint: FORM_ENDPOINT,
85
92
  putEndpoint: (formId) => `${FORM_ENDPOINT}/${formId}`,
86
93
  });
@@ -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);
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../lib/php-files-command.js").CommandClass<import("../../lib/php-files-command.js").RmFilesCommand>;
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ import { HOOK_FILES_RESOURCE, resourceRmCommand } from '../../lib/php-files-command.js';
2
+ export default resourceRmCommand(HOOK_FILES_RESOURCE);
@@ -16,8 +16,8 @@ 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
21
  { dir: 'apps', key: 'appsDir', label: 'Single-page apps' },
22
22
  ];
23
23
  export default class Init extends Command {
@@ -13,8 +13,9 @@ export default class Login extends Command {
13
13
  }
14
14
  async waitForCallback() {
15
15
  return waitForLocalCallback({
16
- buildUrl(callbackBaseUrl) {
17
- const callbackUrl = encodeURIComponent(`${callbackBaseUrl}/callback`);
16
+ allowedOrigins: [CONSOLE_URL],
17
+ buildUrl(callbackBaseUrl, state) {
18
+ const callbackUrl = encodeURIComponent(`${callbackBaseUrl}/callback?state=${state}`);
18
19
  return `${CONSOLE_URL}/cli-auth?callbackUrl=${callbackUrl}`;
19
20
  },
20
21
  handleRequest(url, { resolveWithPage, respondBadRequest }) {
@@ -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
+ }