@loopress/cli 0.23.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 (82) hide show
  1. package/README.md +515 -83
  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/composer/pull.js +6 -16
  13. package/dist/commands/dev.js +1 -1
  14. package/dist/commands/diff.d.ts +13 -0
  15. package/dist/commands/diff.js +42 -0
  16. package/dist/commands/form/diff.d.ts +2 -0
  17. package/dist/commands/form/diff.js +5 -0
  18. package/dist/commands/hook/diff.d.ts +2 -0
  19. package/dist/commands/hook/diff.js +5 -0
  20. package/dist/commands/hook/list.d.ts +2 -0
  21. package/dist/commands/hook/list.js +2 -0
  22. package/dist/commands/hook/pull.d.ts +2 -0
  23. package/dist/commands/hook/pull.js +2 -0
  24. package/dist/commands/hook/push.d.ts +2 -0
  25. package/dist/commands/hook/push.js +2 -0
  26. package/dist/commands/init.js +1 -1
  27. package/dist/commands/option/add.d.ts +15 -0
  28. package/dist/commands/option/add.js +47 -0
  29. package/dist/commands/option/diff.d.ts +2 -0
  30. package/dist/commands/option/diff.js +5 -0
  31. package/dist/commands/{page → option}/list.d.ts +5 -1
  32. package/dist/commands/option/list.js +151 -0
  33. package/dist/commands/{page → option}/pull.d.ts +3 -12
  34. package/dist/commands/option/pull.js +75 -0
  35. package/dist/commands/{page → option}/push.d.ts +3 -8
  36. package/dist/commands/option/push.js +66 -0
  37. package/dist/commands/option/remove.d.ts +22 -0
  38. package/dist/commands/option/remove.js +69 -0
  39. package/dist/commands/plugin/push.d.ts +0 -1
  40. package/dist/commands/plugin/push.js +3 -10
  41. package/dist/commands/pull.js +3 -2
  42. package/dist/commands/push.js +4 -3
  43. package/dist/commands/seo/diff.d.ts +2 -0
  44. package/dist/commands/seo/diff.js +5 -0
  45. package/dist/commands/snippet/diff.d.ts +2 -0
  46. package/dist/commands/snippet/diff.js +5 -0
  47. package/dist/commands/theme/push.d.ts +0 -1
  48. package/dist/commands/theme/push.js +3 -10
  49. package/dist/lib/base.d.ts +2 -1
  50. package/dist/lib/base.js +12 -22
  51. package/dist/lib/dev-watch.d.ts +1 -1
  52. package/dist/lib/dev-watch.js +1 -2
  53. package/dist/lib/diff-command.d.ts +47 -0
  54. package/dist/lib/diff-command.js +180 -0
  55. package/dist/lib/diff-state.d.ts +22 -0
  56. package/dist/lib/diff-state.js +88 -0
  57. package/dist/lib/find-orphaned-files.js +1 -1
  58. package/dist/lib/interactive.d.ts +1 -0
  59. package/dist/lib/interactive.js +10 -0
  60. package/dist/lib/php-files-command.d.ts +54 -0
  61. package/dist/lib/php-files-command.js +216 -0
  62. package/dist/lib/put-or-create.js +1 -1
  63. package/dist/lib/readdir-tolerant.d.ts +1 -0
  64. package/dist/lib/readdir-tolerant.js +3 -1
  65. package/dist/lib/resource-state.d.ts +14 -0
  66. package/dist/lib/resource-state.js +361 -0
  67. package/dist/lib/validate-local.js +15 -7
  68. package/dist/lib/wp-client.d.ts +5 -0
  69. package/dist/lib/wp-client.js +56 -0
  70. package/dist/types/project-config.generated.d.ts +8 -4
  71. package/dist/utils/option-format.d.ts +31 -0
  72. package/dist/utils/option-format.js +51 -0
  73. package/dist/utils/resource-dirs.d.ts +13 -0
  74. package/dist/utils/resource-dirs.js +34 -0
  75. package/oclif.manifest.json +1156 -149
  76. package/package.json +10 -7
  77. package/schemas/project-config.schema.json +10 -5
  78. package/dist/commands/page/list.js +0 -19
  79. package/dist/commands/page/pull.js +0 -57
  80. package/dist/commands/page/push.js +0 -132
  81. package/dist/utils/page-format.d.ts +0 -7
  82. package/dist/utils/page-format.js +0 -78
package/dist/lib/base.js CHANGED
@@ -6,6 +6,7 @@ import { join } from 'node:path';
6
6
  import { configManager } from '../config/project-config.manager.js';
7
7
  import { readLocalConfig } from '../utils/loopress-config.js';
8
8
  import { pluralize } from '../utils/pluralize.js';
9
+ import { resolveResourceDir } from '../utils/resource-dirs.js';
9
10
  import { isInteractive } from './interactive.js';
10
11
  import { isAppPasswordStale, rotateAppPassword } from './rotate-app-password.js';
11
12
  import { WpClient } from './wp-client.js';
@@ -132,39 +133,28 @@ export class LoopressCommand extends Command {
132
133
  }
133
134
  }
134
135
  resolveAcfPath(override) {
135
- if (override)
136
- return override;
137
- return join(this.rootDir, this.localConfig.acfDir ?? 'acf');
136
+ return resolveResourceDir('acf', this.localConfig, override);
138
137
  }
139
138
  resolveApiPath(override) {
140
- if (override)
141
- return override;
142
- return join(this.rootDir, this.localConfig.apiDir ?? 'api');
139
+ return resolveResourceDir('api', this.localConfig, override);
143
140
  }
144
141
  resolveAppsPath(override) {
145
- if (override)
146
- return override;
147
- return join(this.rootDir, this.localConfig.appsDir ?? 'apps');
142
+ return resolveResourceDir('apps', this.localConfig, override);
148
143
  }
149
144
  resolveFormPath(override) {
150
- if (override)
151
- return override;
152
- return join(this.rootDir, this.localConfig.formDir ?? 'forms');
145
+ return resolveResourceDir('form', this.localConfig, override);
153
146
  }
154
- resolvePagePath(override) {
155
- if (override)
156
- return override;
157
- return join(this.rootDir, this.localConfig.pageDir ?? 'pages');
147
+ resolveHooksPath(override) {
148
+ return resolveResourceDir('hooks', this.localConfig, override);
149
+ }
150
+ resolveOptionsPath(override) {
151
+ return resolveResourceDir('options', this.localConfig, override);
158
152
  }
159
153
  resolveSeoPath(override) {
160
- if (override)
161
- return override;
162
- return join(this.rootDir, this.localConfig.seoDir ?? 'seo');
154
+ return resolveResourceDir('seo', this.localConfig, override);
163
155
  }
164
156
  resolveSnippetsPath(override) {
165
- if (override)
166
- return override;
167
- return join(this.rootDir, this.localConfig.snippetsDir ?? 'snippets');
157
+ return resolveResourceDir('snippets', this.localConfig, override);
168
158
  }
169
159
  pickEnvironment(project, envName) {
170
160
  const env = project.environments[envName];
@@ -1,5 +1,5 @@
1
1
  import { type LoopressLocalConfig } from '../utils/loopress-config.js';
2
- export type ResourceType = 'api' | 'pages' | 'plugins' | 'snippets';
2
+ export type ResourceType = 'api' | 'plugins' | 'snippets';
3
3
  export declare const RESOURCE_TYPES: ResourceType[];
4
4
  export type WatchTarget = {
5
5
  commandId: string;
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join, sep } from 'node:path';
3
- export const RESOURCE_TYPES = ['snippets', 'pages', 'api', 'plugins'];
3
+ export const RESOURCE_TYPES = ['snippets', 'api', 'plugins'];
4
4
  // `only` narrows the default set, `skip` then removes from whatever that set is, same
5
5
  // precedence a combined `--only=a,b --skip=b` reads as: watch a, b, then drop b.
6
6
  export function resolveResourceTypes(only, skip = []) {
@@ -15,7 +15,6 @@ export function buildWatchTargets(types, localConfig, cwd) {
15
15
  const rootDir = join(cwd, localConfig.rootDir ?? '.');
16
16
  const candidates = {
17
17
  api: { commandId: 'api:push', path: join(rootDir, localConfig.apiDir ?? 'api'), type: 'api' },
18
- pages: { commandId: 'page:push', path: join(rootDir, localConfig.pageDir ?? 'pages'), type: 'pages' },
19
18
  plugins: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugins' },
20
19
  snippets: { commandId: 'snippet:push', path: join(rootDir, localConfig.snippetsDir ?? 'snippets'), type: 'snippets' },
21
20
  };
@@ -0,0 +1,47 @@
1
+ import { LoopressCommand } from './base.js';
2
+ import { type ResourceState, type StateChange } from './diff-state.js';
3
+ import { type ResourceStateProvider } from './resource-state.js';
4
+ import { WpClient } from './wp-client.js';
5
+ export type ResourceDiffJson = {
6
+ added: string[];
7
+ changed: StateChange[];
8
+ error?: string;
9
+ removed: string[];
10
+ };
11
+ export type DiffJson = {
12
+ drift: boolean;
13
+ left: string;
14
+ resources: Record<string, ResourceDiffJson>;
15
+ right: string;
16
+ };
17
+ export type DiffTarget = {
18
+ left: () => Promise<ResourceState>;
19
+ resource: string;
20
+ right: () => Promise<ResourceState>;
21
+ title: string;
22
+ };
23
+ type DiffSides = {
24
+ againstWp: undefined | WpClient;
25
+ leftLabel: string;
26
+ rightLabel: string;
27
+ warn: (message: string) => void;
28
+ };
29
+ export declare abstract class DiffCommand extends LoopressCommand {
30
+ static againstFlag: {
31
+ against: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
32
+ };
33
+ protected composerTarget(sides: DiffSides): DiffTarget;
34
+ protected providerTarget(provider: ResourceStateProvider, sides: DiffSides, dirOverride?: string): DiffTarget;
35
+ protected report(targets: DiffTarget[], sides: DiffSides): Promise<DiffJson>;
36
+ protected resolveSides(against: string | undefined): DiffSides;
37
+ abstract run(): Promise<DiffJson>;
38
+ private computeResource;
39
+ private out;
40
+ private renderSection;
41
+ private wpForEnvironment;
42
+ }
43
+ export declare function resourceDiffCommand(resource: string, options: {
44
+ description: string;
45
+ pathNoun: string;
46
+ }): typeof DiffCommand;
47
+ export {};
@@ -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 {};