@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.
- package/README.md +515 -83
- package/dist/commands/acf/diff.d.ts +2 -0
- package/dist/commands/acf/diff.js +5 -0
- package/dist/commands/api/diff.d.ts +2 -0
- package/dist/commands/api/diff.js +5 -0
- package/dist/commands/api/list.d.ts +2 -12
- package/dist/commands/api/list.js +2 -20
- package/dist/commands/api/pull.d.ts +2 -20
- package/dist/commands/api/pull.js +2 -53
- package/dist/commands/api/push.d.ts +2 -22
- package/dist/commands/api/push.js +2 -116
- package/dist/commands/composer/pull.js +6 -16
- package/dist/commands/dev.js +1 -1
- package/dist/commands/diff.d.ts +13 -0
- package/dist/commands/diff.js +42 -0
- package/dist/commands/form/diff.d.ts +2 -0
- package/dist/commands/form/diff.js +5 -0
- package/dist/commands/hook/diff.d.ts +2 -0
- package/dist/commands/hook/diff.js +5 -0
- package/dist/commands/hook/list.d.ts +2 -0
- package/dist/commands/hook/list.js +2 -0
- package/dist/commands/hook/pull.d.ts +2 -0
- package/dist/commands/hook/pull.js +2 -0
- package/dist/commands/hook/push.d.ts +2 -0
- package/dist/commands/hook/push.js +2 -0
- package/dist/commands/init.js +1 -1
- package/dist/commands/option/add.d.ts +15 -0
- package/dist/commands/option/add.js +47 -0
- package/dist/commands/option/diff.d.ts +2 -0
- package/dist/commands/option/diff.js +5 -0
- package/dist/commands/{page → option}/list.d.ts +5 -1
- package/dist/commands/option/list.js +151 -0
- package/dist/commands/{page → option}/pull.d.ts +3 -12
- package/dist/commands/option/pull.js +75 -0
- package/dist/commands/{page → option}/push.d.ts +3 -8
- package/dist/commands/option/push.js +66 -0
- package/dist/commands/option/remove.d.ts +22 -0
- package/dist/commands/option/remove.js +69 -0
- package/dist/commands/plugin/push.d.ts +0 -1
- package/dist/commands/plugin/push.js +3 -10
- package/dist/commands/pull.js +3 -2
- package/dist/commands/push.js +4 -3
- package/dist/commands/seo/diff.d.ts +2 -0
- package/dist/commands/seo/diff.js +5 -0
- package/dist/commands/snippet/diff.d.ts +2 -0
- package/dist/commands/snippet/diff.js +5 -0
- package/dist/commands/theme/push.d.ts +0 -1
- package/dist/commands/theme/push.js +3 -10
- package/dist/lib/base.d.ts +2 -1
- package/dist/lib/base.js +12 -22
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +1 -2
- package/dist/lib/diff-command.d.ts +47 -0
- package/dist/lib/diff-command.js +180 -0
- package/dist/lib/diff-state.d.ts +22 -0
- package/dist/lib/diff-state.js +88 -0
- package/dist/lib/find-orphaned-files.js +1 -1
- package/dist/lib/interactive.d.ts +1 -0
- package/dist/lib/interactive.js +10 -0
- package/dist/lib/php-files-command.d.ts +54 -0
- package/dist/lib/php-files-command.js +216 -0
- package/dist/lib/put-or-create.js +1 -1
- package/dist/lib/readdir-tolerant.d.ts +1 -0
- package/dist/lib/readdir-tolerant.js +3 -1
- package/dist/lib/resource-state.d.ts +14 -0
- package/dist/lib/resource-state.js +361 -0
- package/dist/lib/validate-local.js +15 -7
- package/dist/lib/wp-client.d.ts +5 -0
- package/dist/lib/wp-client.js +56 -0
- package/dist/types/project-config.generated.d.ts +8 -4
- package/dist/utils/option-format.d.ts +31 -0
- package/dist/utils/option-format.js +51 -0
- package/dist/utils/resource-dirs.d.ts +13 -0
- package/dist/utils/resource-dirs.js +34 -0
- package/oclif.manifest.json +1156 -149
- package/package.json +10 -7
- package/schemas/project-config.schema.json +10 -5
- package/dist/commands/page/list.js +0 -19
- package/dist/commands/page/pull.js +0 -57
- package/dist/commands/page/push.js +0 -132
- package/dist/utils/page-format.d.ts +0 -7
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
return override;
|
|
152
|
-
return join(this.rootDir, this.localConfig.formDir ?? 'forms');
|
|
145
|
+
return resolveResourceDir('form', this.localConfig, override);
|
|
153
146
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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];
|
package/dist/lib/dev-watch.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type LoopressLocalConfig } from '../utils/loopress-config.js';
|
|
2
|
-
export type ResourceType = 'api' | '
|
|
2
|
+
export type ResourceType = 'api' | 'plugins' | 'snippets';
|
|
3
3
|
export declare const RESOURCE_TYPES: ResourceType[];
|
|
4
4
|
export type WatchTarget = {
|
|
5
5
|
commandId: string;
|
package/dist/lib/dev-watch.js
CHANGED
|
@@ -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', '
|
|
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 (
|
|
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) {
|
package/dist/lib/interactive.js
CHANGED
|
@@ -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 {};
|