@loopress/cli 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +794 -85
  2. package/dist/commands/acf/diff.d.ts +2 -0
  3. package/dist/commands/acf/diff.js +5 -0
  4. package/dist/commands/api/diff.d.ts +2 -0
  5. package/dist/commands/api/diff.js +5 -0
  6. package/dist/commands/api/list.d.ts +2 -12
  7. package/dist/commands/api/list.js +2 -20
  8. package/dist/commands/api/pull.d.ts +2 -20
  9. package/dist/commands/api/pull.js +2 -53
  10. package/dist/commands/api/push.d.ts +2 -22
  11. package/dist/commands/api/push.js +2 -116
  12. package/dist/commands/app/list.d.ts +17 -0
  13. package/dist/commands/app/list.js +35 -0
  14. package/dist/commands/{page → app}/pull.d.ts +7 -5
  15. package/dist/commands/app/pull.js +94 -0
  16. package/dist/commands/app/push.d.ts +28 -0
  17. package/dist/commands/app/push.js +130 -0
  18. package/dist/commands/app/remove.d.ts +20 -0
  19. package/dist/commands/app/remove.js +42 -0
  20. package/dist/commands/composer/pull.js +6 -16
  21. package/dist/commands/composer/push.d.ts +1 -0
  22. package/dist/commands/composer/push.js +43 -19
  23. package/dist/commands/dev.js +1 -1
  24. package/dist/commands/diff.d.ts +13 -0
  25. package/dist/commands/diff.js +42 -0
  26. package/dist/commands/form/diff.d.ts +2 -0
  27. package/dist/commands/form/diff.js +5 -0
  28. package/dist/commands/hook/diff.d.ts +2 -0
  29. package/dist/commands/hook/diff.js +5 -0
  30. package/dist/commands/hook/list.d.ts +2 -0
  31. package/dist/commands/hook/list.js +2 -0
  32. package/dist/commands/hook/pull.d.ts +2 -0
  33. package/dist/commands/hook/pull.js +2 -0
  34. package/dist/commands/hook/push.d.ts +2 -0
  35. package/dist/commands/hook/push.js +2 -0
  36. package/dist/commands/init.js +2 -1
  37. package/dist/commands/option/add.d.ts +15 -0
  38. package/dist/commands/option/add.js +47 -0
  39. package/dist/commands/option/diff.d.ts +2 -0
  40. package/dist/commands/option/diff.js +5 -0
  41. package/dist/commands/{page → option}/list.d.ts +5 -1
  42. package/dist/commands/option/list.js +151 -0
  43. package/dist/commands/option/pull.d.ts +16 -0
  44. package/dist/commands/option/pull.js +75 -0
  45. package/dist/commands/{page → option}/push.d.ts +3 -8
  46. package/dist/commands/option/push.js +66 -0
  47. package/dist/commands/option/remove.d.ts +22 -0
  48. package/dist/commands/option/remove.js +69 -0
  49. package/dist/commands/plugin/add.d.ts +1 -0
  50. package/dist/commands/plugin/add.js +18 -8
  51. package/dist/commands/plugin/audit.d.ts +30 -0
  52. package/dist/commands/plugin/audit.js +138 -0
  53. package/dist/commands/plugin/pull.d.ts +1 -2
  54. package/dist/commands/plugin/pull.js +11 -11
  55. package/dist/commands/plugin/push.d.ts +13 -8
  56. package/dist/commands/plugin/push.js +161 -67
  57. package/dist/commands/plugin/status.d.ts +22 -0
  58. package/dist/commands/plugin/status.js +65 -0
  59. package/dist/commands/pull.js +3 -2
  60. package/dist/commands/push.js +4 -3
  61. package/dist/commands/seo/diff.d.ts +2 -0
  62. package/dist/commands/seo/diff.js +5 -0
  63. package/dist/commands/snippet/diff.d.ts +2 -0
  64. package/dist/commands/snippet/diff.js +5 -0
  65. package/dist/commands/theme/add.d.ts +13 -0
  66. package/dist/commands/theme/add.js +35 -0
  67. package/dist/commands/theme/pull.d.ts +21 -0
  68. package/dist/commands/theme/pull.js +37 -0
  69. package/dist/commands/theme/push.d.ts +22 -0
  70. package/dist/commands/theme/push.js +123 -0
  71. package/dist/commands/theme/status.d.ts +20 -0
  72. package/dist/commands/theme/status.js +51 -0
  73. package/dist/lib/app-manifest.d.ts +36 -0
  74. package/dist/lib/app-manifest.js +162 -0
  75. package/dist/lib/base.d.ts +3 -1
  76. package/dist/lib/base.js +14 -19
  77. package/dist/lib/dev-watch.d.ts +1 -1
  78. package/dist/lib/dev-watch.js +1 -2
  79. package/dist/lib/diff-command.d.ts +47 -0
  80. package/dist/lib/diff-command.js +180 -0
  81. package/dist/lib/diff-state.d.ts +22 -0
  82. package/dist/lib/diff-state.js +88 -0
  83. package/dist/lib/find-orphaned-files.js +1 -1
  84. package/dist/lib/interactive.d.ts +1 -0
  85. package/dist/lib/interactive.js +10 -0
  86. package/dist/lib/php-files-command.d.ts +54 -0
  87. package/dist/lib/php-files-command.js +216 -0
  88. package/dist/lib/put-or-create.js +1 -1
  89. package/dist/lib/readdir-tolerant.d.ts +1 -0
  90. package/dist/lib/readdir-tolerant.js +3 -1
  91. package/dist/lib/resource-state.d.ts +14 -0
  92. package/dist/lib/resource-state.js +361 -0
  93. package/dist/lib/validate-local.js +15 -7
  94. package/dist/lib/wp-client.d.ts +5 -0
  95. package/dist/lib/wp-client.js +56 -0
  96. package/dist/types/plugin.d.ts +1 -0
  97. package/dist/types/project-config.generated.d.ts +19 -5
  98. package/dist/utils/option-format.d.ts +31 -0
  99. package/dist/utils/option-format.js +51 -0
  100. package/dist/utils/plugin-sync.d.ts +26 -0
  101. package/dist/utils/plugin-sync.js +31 -0
  102. package/dist/utils/plugins.d.ts +15 -2
  103. package/dist/utils/plugins.js +57 -12
  104. package/dist/utils/resource-dirs.d.ts +13 -0
  105. package/dist/utils/resource-dirs.js +34 -0
  106. package/dist/utils/themes.d.ts +30 -0
  107. package/dist/utils/themes.js +29 -0
  108. package/dist/utils/version.d.ts +2 -0
  109. package/dist/utils/version.js +21 -0
  110. package/oclif.manifest.json +1897 -244
  111. package/package.json +26 -19
  112. package/schemas/project-config.schema.json +32 -9
  113. package/dist/commands/page/list.js +0 -19
  114. package/dist/commands/page/pull.js +0 -57
  115. package/dist/commands/page/push.js +0 -132
  116. package/dist/utils/page-format.d.ts +0 -7
  117. package/dist/utils/page-format.js +0 -78
@@ -0,0 +1,180 @@
1
+ import { Args, Flags, ux } from '@oclif/core';
2
+ import { resolve } from 'node:path';
3
+ import { configManager } from '../config/project-config.manager.js';
4
+ import { resolveResourceDir } from '../utils/resource-dirs.js';
5
+ import { LoopressCommand } from './base.js';
6
+ import { compareStates, isEmptyDiff } from './diff-state.js';
7
+ import { composerLocalState, composerRemoteState, getResourceStateProvider } from './resource-state.js';
8
+ import { WpClient } from './wp-client.js';
9
+ const c = ux.colorize;
10
+ // Shared machinery for `lps diff` and the per-resource `lps <resource> diff` commands: the
11
+ // `--against` flag, resolving the two sides, turning a provider into a comparison target, and
12
+ // running + rendering the comparison with a CI-friendly exit code.
13
+ export class DiffCommand extends LoopressCommand {
14
+ // Spread into each concrete command's `static flags`, same pattern as PushCommand.dryRunFlag.
15
+ static againstFlag = {
16
+ against: Flags.string({
17
+ description: 'Compare the primary environment against this second environment instead of against local files',
18
+ }),
19
+ };
20
+ composerTarget(sides) {
21
+ // resolve(), not join(): an absolute `rootDir` in loopress.json must stay absolute rather
22
+ // than being prefixed with the cwd, so Composer is read from the same place as the
23
+ // file-backed resources.
24
+ const localRoot = resolve(process.cwd(), this.rootDir);
25
+ return {
26
+ left: async () => composerRemoteState(this.wp),
27
+ resource: 'composer',
28
+ right: sides.againstWp ? async () => composerRemoteState(sides.againstWp) : async () => composerLocalState(localRoot),
29
+ title: 'Composer',
30
+ };
31
+ }
32
+ // Turns a directory-backed provider into a target; `dirOverride` is the optional `[PATH]`
33
+ // arg the per-resource commands accept.
34
+ providerTarget(provider, sides, dirOverride) {
35
+ if (dirOverride !== undefined && sides.againstWp) {
36
+ this.error('A [PATH] argument cannot be combined with --against: --against compares two environments, not local files.');
37
+ }
38
+ const dir = resolveResourceDir(provider.dirKind, this.localConfig, dirOverride);
39
+ return {
40
+ left: async () => provider.remote(this.wp, sides.warn, dir),
41
+ resource: provider.resource,
42
+ right: sides.againstWp ? async () => provider.remote(sides.againstWp, sides.warn, dir) : async () => provider.local(dir, sides.warn),
43
+ title: provider.title,
44
+ };
45
+ }
46
+ // Runs every target (concurrently), prints a git-style section per resource in target order,
47
+ // a tally, and the verdict, then returns the structured result. Exit code: 1 on drift, 2
48
+ // when a resource could not be compared (inconclusive), so the command doubles as a CI gate.
49
+ async report(targets, sides) {
50
+ this.out(`Comparing ${c('bold', sides.leftLabel)} ${c('dim', '→')} ${c('bold', sides.rightLabel)}\n`);
51
+ const results = await Promise.all(targets.map(async (target) => this.computeResource(target, sides)));
52
+ const resources = {};
53
+ let drift = false;
54
+ let failed = false;
55
+ let compared = 0;
56
+ let added = 0;
57
+ let removed = 0;
58
+ let changed = 0;
59
+ for (const result of results) {
60
+ resources[result.resource] = result.json;
61
+ if (result.json.error !== undefined) {
62
+ failed = true;
63
+ const errorLabel = c('red', `error: ${result.json.error}`);
64
+ this.out(`${c('bold', result.title)} ${errorLabel}`);
65
+ continue;
66
+ }
67
+ compared += 1;
68
+ added += result.json.added.length;
69
+ removed += result.json.removed.length;
70
+ changed += result.json.changed.length;
71
+ if (result.stateDiff)
72
+ this.renderSection(result.title, result.stateDiff);
73
+ if (result.drift)
74
+ drift = true;
75
+ }
76
+ this.out(`\n${compared} compared: ${changed} changed, ${added} added, ${removed} removed`);
77
+ if (failed)
78
+ this.out(c('red', 'Some resources could not be compared; this run is inconclusive.'));
79
+ else
80
+ this.out(drift ? c('yellow', 'Drift detected.') : c('green', 'Everything is in sync.'));
81
+ if (failed)
82
+ process.exitCode = 2;
83
+ else if (drift)
84
+ process.exitCode = 1;
85
+ return { drift, left: sides.leftLabel, resources, right: sides.rightLabel };
86
+ }
87
+ resolveSides(against) {
88
+ if (against === this.siteConfig.name) {
89
+ this.error('--against must be a different environment from the one being compared.');
90
+ }
91
+ return {
92
+ againstWp: against ? this.wpForEnvironment(against) : undefined,
93
+ leftLabel: this.siteConfig.name,
94
+ rightLabel: against ?? 'local',
95
+ warn: (message) => {
96
+ this.warn(message);
97
+ },
98
+ };
99
+ }
100
+ // Fetches and compares one target. No output (so targets can run concurrently); `report`
101
+ // renders `stateDiff` afterwards in a fixed order.
102
+ async computeResource(target, sides) {
103
+ try {
104
+ const [leftState, rightState] = await Promise.all([target.left(), target.right()]);
105
+ const stateDiff = compareStates(leftState, rightState, { left: sides.leftLabel, right: sides.rightLabel });
106
+ return {
107
+ drift: !isEmptyDiff(stateDiff),
108
+ json: { added: stateDiff.added, changed: stateDiff.changed, removed: stateDiff.removed },
109
+ resource: target.resource,
110
+ stateDiff,
111
+ title: target.title,
112
+ };
113
+ }
114
+ catch (error) {
115
+ const { message } = error;
116
+ return { drift: false, json: { added: [], changed: [], error: message, removed: [] }, resource: target.resource, title: target.title };
117
+ }
118
+ }
119
+ out(message) {
120
+ if (!this.jsonEnabled())
121
+ this.log(message);
122
+ }
123
+ renderSection(title, stateDiff) {
124
+ if (isEmptyDiff(stateDiff)) {
125
+ this.out(`${c('bold', title)} ${c('dim', 'in sync')}`);
126
+ return;
127
+ }
128
+ this.out(c('bold', title));
129
+ for (const id of stateDiff.added)
130
+ this.out(c('green', ` + ${id}`));
131
+ for (const id of stateDiff.removed)
132
+ this.out(c('red', ` - ${id}`));
133
+ for (const change of stateDiff.changed) {
134
+ this.out(c('yellow', ` ~ ${change.id}`));
135
+ this.out(indentPatch(change.patch));
136
+ }
137
+ }
138
+ wpForEnvironment(envName) {
139
+ const env = configManager.getEnvironment(this.projectId, envName);
140
+ if (!env) {
141
+ this.error(`Environment "${envName}" not found in this project.`);
142
+ }
143
+ if (!env.token) {
144
+ this.error(`No credentials configured for "${envName}". Run \`lps project config\` to add them.`);
145
+ }
146
+ return new WpClient(env.url, env.token);
147
+ }
148
+ }
149
+ // Builds the `lps <resource> diff` command class for one directory-backed resource. They differ
150
+ // only in wording and which provider they target, so the whole body lives here.
151
+ export function resourceDiffCommand(resource, options) {
152
+ class ResourceDiff extends DiffCommand {
153
+ static args = {
154
+ path: Args.string({ description: `Path to ${options.pathNoun} (overrides project config)` }),
155
+ };
156
+ static description = options.description;
157
+ static enableJsonFlag = true;
158
+ static examples = [
159
+ `$ lps ${resource} diff`,
160
+ `$ lps ${resource} diff --env staging`,
161
+ `$ lps ${resource} diff --env staging --against production`,
162
+ ];
163
+ static flags = { ...DiffCommand.againstFlag };
164
+ async run() {
165
+ const { args, flags } = await this.parse(ResourceDiff);
166
+ const sides = this.resolveSides(flags.against);
167
+ return this.report([this.providerTarget(getResourceStateProvider(resource), sides, args.path)], sides);
168
+ }
169
+ }
170
+ return ResourceDiff;
171
+ }
172
+ // Indents an already-formatted change block (a per-field list or a header-less unified diff)
173
+ // so it sits visually under the `~ id` line.
174
+ function indentPatch(patch) {
175
+ return patch
176
+ .split('\n')
177
+ .map((line) => (line === '' ? line : ` ${line}`))
178
+ .join('\n')
179
+ .trimEnd();
180
+ }
@@ -0,0 +1,22 @@
1
+ export type ResourceState = Map<string, unknown>;
2
+ export type FieldChange = {
3
+ from?: unknown;
4
+ kind: 'added' | 'changed' | 'removed';
5
+ path: string;
6
+ to?: unknown;
7
+ };
8
+ export type StateChange = {
9
+ fields?: FieldChange[];
10
+ id: string;
11
+ patch: string;
12
+ };
13
+ export type StateDiff = {
14
+ added: string[];
15
+ changed: StateChange[];
16
+ removed: string[];
17
+ };
18
+ export declare function compareStates(left: ResourceState, right: ResourceState, labels: {
19
+ left: string;
20
+ right: string;
21
+ }): StateDiff;
22
+ export declare function isEmptyDiff(diff: StateDiff): boolean;
@@ -0,0 +1,88 @@
1
+ import { createTwoFilesPatch } from 'diff';
2
+ import microdiff from 'microdiff';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+ const MAX_VALUE_LENGTH = 200;
5
+ function isRecord(value) {
6
+ return typeof value === 'object' && value !== null;
7
+ }
8
+ function formatValue(value) {
9
+ const text = JSON.stringify(value) ?? String(value);
10
+ return text.length > MAX_VALUE_LENGTH ? `${text.slice(0, MAX_VALUE_LENGTH - 1)}…` : text;
11
+ }
12
+ // createTwoFilesPatch treats a missing final newline as a "" hunk
13
+ // line, noise when both sides lack it; normalize so only real differences surface.
14
+ function ensureTrailingNewline(text) {
15
+ return text.endsWith('\n') ? text : `${text}\n`;
16
+ }
17
+ // Two strings (an API route file, a hook file, composer.json): a real line diff. Only the
18
+ // `Index:` / `===` preamble createTwoFilesPatch emits is dropped; the `--- <left>` / `+++
19
+ // <right>` lines are kept so each hunk still states its own direction.
20
+ function renderTextChange(id, left, right, labels) {
21
+ const patch = createTwoFilesPatch(id, id, ensureTrailingNewline(left), ensureTrailingNewline(right), labels.left, labels.right);
22
+ const lines = patch.split('\n');
23
+ const start = lines.findIndex((line) => line.startsWith('--- '));
24
+ return (start === -1 ? lines : lines.slice(start)).join('\n').trimEnd();
25
+ }
26
+ // Two objects: microdiff walks them structurally and returns one entry per changed leaf, so
27
+ // key ordering never matters and a big nested blob (a WPForms definition, an ACF group)
28
+ // reduces to just the fields that moved.
29
+ function objectFieldChanges(left, right) {
30
+ return microdiff(left, right, { cyclesFix: false }).map((change) => {
31
+ const path = change.path.join('.');
32
+ if (change.type === 'CREATE')
33
+ return { kind: 'added', path, to: change.value };
34
+ if (change.type === 'REMOVE')
35
+ return { from: change.oldValue, kind: 'removed', path };
36
+ return { from: change.oldValue, kind: 'changed', path, to: change.value };
37
+ });
38
+ }
39
+ function renderFieldChanges(changes) {
40
+ return changes
41
+ .map((change) => {
42
+ if (change.kind === 'added')
43
+ return `+ ${change.path}: ${formatValue(change.to)}`;
44
+ if (change.kind === 'removed')
45
+ return `- ${change.path}: ${formatValue(change.from)}`;
46
+ return `~ ${change.path}: ${formatValue(change.from)} → ${formatValue(change.to)}`;
47
+ })
48
+ .join('\n');
49
+ }
50
+ function changeFor(id, left, right, labels) {
51
+ if (typeof left === 'string' && typeof right === 'string') {
52
+ return { id, patch: renderTextChange(id, left, right, labels) };
53
+ }
54
+ if (isRecord(left) && isRecord(right)) {
55
+ const fields = objectFieldChanges(left, right);
56
+ return { fields, id, patch: renderFieldChanges(fields) };
57
+ }
58
+ // Type mismatch between the two sides (e.g. a field that was a string and is now an object).
59
+ return { id, patch: `~ ${formatValue(left)} → ${formatValue(right)}` };
60
+ }
61
+ // `left` is the reference, `right` is the subject. For `lps diff` with no env-vs-env args
62
+ // that's remote (left) vs local (right), so `added` reads as "present locally, not on the
63
+ // site" and `removed` as "on the site, not locally".
64
+ export function compareStates(left, right, labels) {
65
+ const diff = { added: [], changed: [], removed: [] };
66
+ for (const id of right.keys()) {
67
+ if (!left.has(id))
68
+ diff.added.push(id);
69
+ }
70
+ for (const id of left.keys()) {
71
+ if (!right.has(id)) {
72
+ diff.removed.push(id);
73
+ continue;
74
+ }
75
+ const leftValue = left.get(id);
76
+ const rightValue = right.get(id);
77
+ if (isDeepStrictEqual(leftValue, rightValue))
78
+ continue;
79
+ diff.changed.push(changeFor(id, leftValue, rightValue, labels));
80
+ }
81
+ diff.added.sort((a, b) => a.localeCompare(b));
82
+ diff.removed.sort((a, b) => a.localeCompare(b));
83
+ diff.changed.sort((a, b) => a.id.localeCompare(b.id));
84
+ return diff;
85
+ }
86
+ export function isEmptyDiff(diff) {
87
+ return diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0;
88
+ }
@@ -1,7 +1,7 @@
1
1
  import { readdir } from 'node:fs/promises';
2
2
  import { extname, join } from 'node:path';
3
3
  // Same shape as load-files.ts's own walker, kept separate rather than shared: this one
4
- // filters against a list of extensions (page/snippet pull need more than one), that one
4
+ // filters against a list of extensions (snippet pull needs more than one), that one
5
5
  // against a single extension, and reconciling the two would cost more than the ~15 duplicated
6
6
  // lines it would save.
7
7
  async function walk(dir, extensions) {
@@ -1 +1,2 @@
1
1
  export declare function isInteractive(): boolean;
2
+ export declare function confirmUninstall(toRemove: string[], yes: boolean): Promise<boolean>;
@@ -1,3 +1,4 @@
1
+ import { confirm } from '@inquirer/prompts';
1
2
  // Whether prompts can be shown at all: a real terminal on both ends, and not a CI runner.
2
3
  // CI=1 with a pseudo-TTY still counts as non-interactive, which is what CI users expect.
3
4
  // Every command that prompts routes its decision through this so the CLI never hangs on a
@@ -5,3 +6,12 @@
5
6
  export function isInteractive() {
6
7
  return process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
7
8
  }
9
+ // The "about to uninstall N plugins/themes from the site" gate shared by `plugin push` and
10
+ // `theme push`. Returns whether the caller may proceed: nothing to remove, --yes, or a
11
+ // non-interactive shell all pass straight through; otherwise the user is asked and a "no"
12
+ // returns false so the caller can abort.
13
+ export async function confirmUninstall(toRemove, yes) {
14
+ if (yes || toRemove.length === 0 || !isInteractive())
15
+ return true;
16
+ return confirm({ default: false, message: `Uninstall ${toRemove.join(', ')} from the site?` });
17
+ }
@@ -0,0 +1,54 @@
1
+ import { type Config } from '@oclif/core';
2
+ import { type EnvironmentConfig } from '../types/config.js';
3
+ import { type ResourceDirKind } from '../utils/resource-dirs.js';
4
+ import { LoopressCommand } from './base.js';
5
+ import { PushCommand } from './push-command.js';
6
+ export type PhpFilesResource = {
7
+ afterPush?: (context: {
8
+ filenames: string[];
9
+ siteConfig: EnvironmentConfig;
10
+ }) => Promise<void>;
11
+ cliName: string;
12
+ dirKind: ResourceDirKind;
13
+ emptyListMessage: string;
14
+ endpoint: string;
15
+ filenamePattern: RegExp;
16
+ invalidFilenameHint: string;
17
+ label: string;
18
+ listDescription: string;
19
+ noun: string;
20
+ orphanReason: string;
21
+ pathLabel: string;
22
+ pathNoun: string;
23
+ pullDescription: string;
24
+ pushDescription: string;
25
+ };
26
+ type PhpFile = {
27
+ content: string;
28
+ filename: string;
29
+ };
30
+ type PushResult = {
31
+ pushed: string[];
32
+ status: 'dry-run' | 'success';
33
+ };
34
+ type PullResult = {
35
+ orphans: string[];
36
+ pulled: string[];
37
+ status: 'dry-run' | 'success';
38
+ };
39
+ export type CommandClass<TInstance> = new (argv: string[], config: Config) => TInstance;
40
+ export type PushFilesCommand = Omit<PushCommand, 'run'> & {
41
+ run(): Promise<PushResult>;
42
+ };
43
+ export type PullFilesCommand = Omit<LoopressCommand, 'run'> & {
44
+ run(): Promise<PullResult>;
45
+ };
46
+ export type ListFilesCommand = Omit<LoopressCommand, 'run'> & {
47
+ run(): Promise<PhpFile[]>;
48
+ };
49
+ export declare function resourcePushCommand(spec: PhpFilesResource): CommandClass<PushFilesCommand>;
50
+ export declare function resourcePullCommand(spec: PhpFilesResource): CommandClass<PullFilesCommand>;
51
+ export declare function resourceListCommand(spec: PhpFilesResource): CommandClass<ListFilesCommand>;
52
+ export declare const API_FILES_RESOURCE: PhpFilesResource;
53
+ export declare const HOOK_FILES_RESOURCE: PhpFilesResource;
54
+ export {};
@@ -0,0 +1,216 @@
1
+ import { Args } from '@oclif/core';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, relative, sep } from 'node:path';
4
+ import { authManager } from '../config/auth.manager.js';
5
+ import { pluralize } from '../utils/pluralize.js';
6
+ import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
7
+ import { ApiClient } from './api-client.js';
8
+ import { LoopressCommand } from './base.js';
9
+ import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
10
+ import { loadFiles as loadDirectoryFiles } from './load-files.js';
11
+ import { PushCommand } from './push-command.js';
12
+ // Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
13
+ // an absent declare(strict_types=1); and one that appears more than once (it needs a single
14
+ // unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
15
+ // presence, so a file that would still fail server-side doesn't falsely pass this check.
16
+ const DECLARE_PATTERN = /declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;/g;
17
+ function pathArg(pathNoun) {
18
+ return { path: Args.string({ description: `Path to ${pathNoun} (overrides project config)` }) };
19
+ }
20
+ // relative()'s separator is OS-specific ('\\' on Windows); the server only ever expects '/',
21
+ // same as any URL or import path.
22
+ async function loadPhpFiles(command, dir) {
23
+ return loadDirectoryFiles(dir, {
24
+ extension: '.php',
25
+ onSkip(message) { command.warn(message); },
26
+ parse: (raw, filePath) => ({
27
+ content: raw,
28
+ filename: relative(dir, filePath).slice(0, -'.php'.length).split(sep).join('/'),
29
+ }),
30
+ recursive: true,
31
+ });
32
+ }
33
+ export function resourcePushCommand(spec) {
34
+ class ResourcePush extends PushCommand {
35
+ static args = pathArg(spec.pathNoun);
36
+ static description = spec.pushDescription;
37
+ static enableJsonFlag = true;
38
+ static examples = [`$ lps ${spec.cliName} push`, `$ lps ${spec.cliName} push --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`];
39
+ static flags = { ...PushCommand.dryRunFlag, ...PushCommand.yesFlag };
40
+ async run() {
41
+ const { args } = await this.parse(ResourcePush);
42
+ const { url } = this.siteConfig;
43
+ const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
44
+ this.log(`Pushing ${spec.label} to ${url}`);
45
+ this.log(`${spec.pathLabel} path: ${path}`);
46
+ const files = await this.loadFiles(path);
47
+ this.log(`Found ${pluralize(files.length, spec.noun)} to push`);
48
+ const pushed = [];
49
+ await this.runPushTasks(files, (file) => file.filename, async (file, task) => {
50
+ await this.pushFile(file, task);
51
+ pushed.push(file.filename);
52
+ });
53
+ if (this.failedCount > 0) {
54
+ this.error(`${pluralize(this.failedCount, spec.noun)} failed to push.`);
55
+ }
56
+ if (this.dryRun)
57
+ return { pushed, status: 'dry-run' };
58
+ await this.recordSuccess();
59
+ await spec.afterPush?.({ filenames: pushed, siteConfig: this.siteConfig });
60
+ this.log(`All ${spec.label} pushed.`);
61
+ return { pushed, status: 'success' };
62
+ }
63
+ async loadFiles(path) {
64
+ return loadPhpFiles(this, path);
65
+ }
66
+ async pushFile(file, task) {
67
+ if (!spec.filenamePattern.test(file.filename)) {
68
+ const message = `Invalid filename "${file.filename}": ${spec.invalidFilenameHint}`;
69
+ this.reportTaskFailure(message, new Error(message), task);
70
+ }
71
+ const declareCount = file.content.match(DECLARE_PATTERN)?.length ?? 0;
72
+ if (declareCount !== 1) {
73
+ const reason = declareCount === 0 ? 'is missing' : 'appears more than once';
74
+ const message = `${file.filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`;
75
+ this.reportTaskFailure(message, new Error(message), task);
76
+ }
77
+ if (this.dryRun) {
78
+ if (task)
79
+ task.output = `[dry-run] Would push: ${file.filename}`;
80
+ return;
81
+ }
82
+ try {
83
+ const result = await this.wp.put(spec.endpoint, {
84
+ content: file.content,
85
+ filename: file.filename,
86
+ });
87
+ if (task) {
88
+ task.output =
89
+ result.syntax_check === 'skipped'
90
+ ? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
91
+ : `Pushed: ${file.filename}`;
92
+ }
93
+ }
94
+ catch (error) {
95
+ this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
96
+ }
97
+ }
98
+ }
99
+ return ResourcePush;
100
+ }
101
+ export function resourcePullCommand(spec) {
102
+ class ResourcePull extends LoopressCommand {
103
+ static args = pathArg(spec.pathNoun);
104
+ static description = spec.pullDescription;
105
+ static enableJsonFlag = true;
106
+ static examples = [`$ lps ${spec.cliName} pull`, `$ lps ${spec.cliName} pull --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`];
107
+ static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
108
+ async run() {
109
+ const { args } = await this.parse(ResourcePull);
110
+ const { url } = this.siteConfig;
111
+ const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
112
+ this.log(`Pulling ${spec.label} from ${url}`);
113
+ this.log(`${spec.pathLabel} path: ${path}`);
114
+ const files = await this.wp.get(spec.endpoint);
115
+ // A `<filename>.php` no longer present remotely was deleted on WordPress (push stays
116
+ // additive-only, but pull already cleans up locally, same as `snippet pull`).
117
+ const orphans = await findOrphanedFiles(path, new Set(files.map((file) => file.filename)), {
118
+ extensions: ['.php'],
119
+ key: basenameKey,
120
+ recursive: true,
121
+ });
122
+ const pulled = files.map((file) => file.filename);
123
+ await this.pullDirectory(path, files, orphans, {
124
+ alwaysCreateDir: true,
125
+ dryRunMessage: `Would pull ${pluralize(files.length, spec.noun)} to ${path}`,
126
+ orphanReason: spec.orphanReason,
127
+ pulledMessage: `Pulled ${pluralize(files.length, spec.noun)} to ${path}`,
128
+ title: (file) => file.filename,
129
+ async write(file, writeDir) {
130
+ const filePath = join(writeDir, `${file.filename}.php`);
131
+ // filename can contain '/' (a nested slug, e.g. content/filters or a path-param
132
+ // route invoice-pdf/[order_id]): writeFile() doesn't create parent directories on
133
+ // its own the way mkdir()'s recursive option does above for the top-level path.
134
+ await mkdir(dirname(filePath), { recursive: true });
135
+ await writeFile(filePath, file.content);
136
+ },
137
+ });
138
+ if (this.dryRun)
139
+ return { orphans, pulled, status: 'dry-run' };
140
+ return { orphans, pulled, status: 'success' };
141
+ }
142
+ }
143
+ return ResourcePull;
144
+ }
145
+ export function resourceListCommand(spec) {
146
+ class ResourceList extends LoopressCommand {
147
+ static description = spec.listDescription;
148
+ static enableJsonFlag = true;
149
+ static examples = [`$ lps ${spec.cliName} list`];
150
+ async run() {
151
+ const files = await this.wp.get(spec.endpoint);
152
+ if (files.length === 0) {
153
+ this.log(spec.emptyListMessage);
154
+ return files;
155
+ }
156
+ this.log(`Found ${pluralize(files.length, spec.noun)}:`);
157
+ this.log('');
158
+ for (const file of files) {
159
+ this.log(` ${file.filename}`);
160
+ }
161
+ return files;
162
+ }
163
+ }
164
+ return ResourceList;
165
+ }
166
+ export const API_FILES_RESOURCE = {
167
+ // Best-effort report of the current route list to the Loopress cloud, purely for console
168
+ // visibility (US-18): the routes are already live on WordPress by this point, so this can
169
+ // never block or fail the push, same reasoning as `recordDeployment` in PushCommand.
170
+ async afterPush({ filenames, siteConfig }) {
171
+ const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token;
172
+ const { apiEnvironmentId } = siteConfig;
173
+ if (!token || !apiEnvironmentId)
174
+ return;
175
+ try {
176
+ await new ApiClient(token).put('api-routes', { environmentId: apiEnvironmentId, filenames });
177
+ }
178
+ catch {
179
+ // non-blocking: reporting the route list must never interrupt the push flow
180
+ }
181
+ },
182
+ cliName: 'api',
183
+ dirKind: 'api',
184
+ emptyListMessage: 'No API route files found',
185
+ endpoint: 'loopress/v1/api-files',
186
+ // Mirrors ApiFilesController::isValidFilename(): kebab-case segments or a bracketed dynamic
187
+ // segment like [order_id], joined by '/'.
188
+ filenamePattern: /^(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/,
189
+ invalidFilenameHint: '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")',
190
+ label: 'API routes',
191
+ listDescription: 'List custom API route files from WordPress',
192
+ noun: 'route file',
193
+ orphanReason: 'whose route no longer exists on WordPress',
194
+ pathLabel: 'API',
195
+ pathNoun: 'api directory',
196
+ pullDescription: 'Pull custom API route files from WordPress',
197
+ pushDescription: 'Push custom API route files to WordPress',
198
+ };
199
+ export const HOOK_FILES_RESOURCE = {
200
+ cliName: 'hook',
201
+ dirKind: 'hooks',
202
+ emptyListMessage: 'No hook files found',
203
+ endpoint: 'loopress/v1/hook-files',
204
+ // Mirrors HookFilesController::isValidFilename(): kebab-case segments joined by '/', no
205
+ // bracketed dynamic-segment alternative (a hook slug is never a URL path).
206
+ filenamePattern: /^[a-z0-9-]+(?:\/[a-z0-9-]+)*$/,
207
+ invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens (e.g. "content-filters.php" or "content/filters.php")',
208
+ label: 'hooks',
209
+ listDescription: 'List hook files (actions, filters, cron) from WordPress',
210
+ noun: 'hook file',
211
+ orphanReason: 'no longer present on WordPress',
212
+ pathLabel: 'Hooks',
213
+ pathNoun: 'hooks directory',
214
+ pullDescription: 'Pull hook files (actions, filters, cron) from WordPress',
215
+ pushDescription: 'Push hook files (actions, filters, cron) to WordPress',
216
+ };
@@ -1,5 +1,5 @@
1
1
  import { isNotFoundError } from './wp-client.js';
2
- // Update-or-create dance shared by every id-based resource (form, page, seo redirect,
2
+ // Update-or-create dance shared by every id-based resource (form, seo redirect,
3
3
  // snippet, unlike ACF which is key-based and only ever POSTs): PUT to the known id, and only
4
4
  // fall back to POST when the site doesn't recognize that id (404), e.g. a fresh install or a
5
5
  // database reset.
@@ -1,5 +1,6 @@
1
1
  import { type Dirent } from 'node:fs';
2
2
  export declare function readdirTolerant(dir: string): Promise<string[]>;
3
3
  export declare function readdirTolerant(dir: string, options: {
4
+ recursive?: boolean;
4
5
  withFileTypes: true;
5
6
  }): Promise<Dirent[]>;
@@ -1,7 +1,9 @@
1
1
  import { readdir } from 'node:fs/promises';
2
2
  export async function readdirTolerant(dir, options) {
3
3
  try {
4
- return options?.withFileTypes ? await readdir(dir, { withFileTypes: true }) : await readdir(dir);
4
+ return options?.withFileTypes
5
+ ? await readdir(dir, { recursive: options.recursive, withFileTypes: true })
6
+ : await readdir(dir, { recursive: options?.recursive });
5
7
  }
6
8
  catch (error) {
7
9
  if (error.code === 'ENOENT')
@@ -0,0 +1,14 @@
1
+ import { type ResourceDirKind } from '../utils/resource-dirs.js';
2
+ import { type ResourceState } from './diff-state.js';
3
+ import { type WpClient } from './wp-client.js';
4
+ export type ResourceStateProvider = {
5
+ dirKind: ResourceDirKind;
6
+ local(dir: string, onWarn: (message: string) => void): Promise<ResourceState>;
7
+ remote(wp: WpClient, onWarn: (message: string) => void, dir: string): Promise<ResourceState>;
8
+ resource: string;
9
+ title: string;
10
+ };
11
+ export declare const RESOURCE_STATE_PROVIDERS: ResourceStateProvider[];
12
+ export declare function getResourceStateProvider(resource: string): ResourceStateProvider;
13
+ export declare function composerRemoteState(wp: WpClient): Promise<ResourceState>;
14
+ export declare function composerLocalState(rootDir: string): Promise<ResourceState>;