@loopress/cli 0.33.0 → 0.34.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 +174 -161
- package/dist/commands/app/push.js +2 -1
- package/dist/commands/block/push.js +9 -4
- package/dist/commands/dev.d.ts +2 -2
- package/dist/commands/dev.js +10 -16
- package/dist/commands/diff.d.ts +6 -4
- package/dist/commands/diff.js +49 -29
- package/dist/commands/doctor.js +12 -2
- package/dist/commands/option/push.js +1 -0
- package/dist/commands/page/push.js +1 -0
- package/dist/commands/plugin/add.d.ts +1 -0
- package/dist/commands/plugin/add.js +50 -9
- package/dist/commands/plugin/push.js +39 -4
- package/dist/commands/project/config.js +0 -1
- package/dist/commands/promote.d.ts +1 -0
- package/dist/commands/promote.js +8 -4
- package/dist/commands/pull.d.ts +5 -0
- package/dist/commands/pull.js +26 -1
- package/dist/commands/push.d.ts +9 -0
- package/dist/commands/push.js +104 -39
- package/dist/commands/theme/style/diff.js +1 -1
- package/dist/commands/theme/style/push.js +1 -1
- package/dist/commands/theme/style/rollback.js +1 -1
- package/dist/commands/theme/template/push.d.ts +2 -0
- package/dist/commands/theme/template/push.js +42 -13
- package/dist/commands/validate.js +3 -0
- package/dist/lib/base.d.ts +6 -0
- package/dist/lib/base.js +33 -1
- package/dist/lib/declared-plugins.d.ts +9 -0
- package/dist/lib/declared-plugins.js +21 -0
- package/dist/lib/declared-type-command.js +3 -0
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +11 -13
- package/dist/lib/diff-command.d.ts +17 -0
- package/dist/lib/diff-command.js +125 -9
- package/dist/lib/environment-urls.d.ts +6 -0
- package/dist/lib/environment-urls.js +59 -0
- package/dist/lib/exclude.d.ts +16 -0
- package/dist/lib/exclude.js +102 -0
- package/dist/lib/load-snippets.js +2 -1
- package/dist/lib/php-files-command.js +5 -3
- package/dist/lib/push-command.d.ts +4 -0
- package/dist/lib/push-command.js +38 -2
- package/dist/lib/readdir-tolerant.js +6 -3
- package/dist/lib/resource-selection.d.ts +9 -0
- package/dist/lib/resource-selection.js +49 -0
- package/dist/lib/resource-state.d.ts +2 -0
- package/dist/lib/resource-state.js +25 -4
- package/dist/lib/rollback-command.js +1 -1
- package/dist/lib/rollback-materialize.js +1 -1
- package/dist/lib/validate-local.d.ts +1 -0
- package/dist/lib/validate-local.js +67 -8
- package/dist/lib/wp-client.d.ts +1 -0
- package/dist/lib/wp-client.js +6 -0
- package/dist/types/project-config.generated.d.ts +15 -4
- package/dist/utils/block-format.d.ts +2 -0
- package/dist/utils/block-format.js +18 -2
- package/dist/utils/page-format.js +2 -1
- package/dist/utils/resource-dirs.js +4 -4
- package/oclif.manifest.json +2275 -1394
- package/package.json +7 -7
- package/schemas/project-config.schema.json +21 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LoopressCommand } from './base.js';
|
|
2
|
+
import { type MissingIntegration } from './capabilities.js';
|
|
2
3
|
import { type ResourceState, type StateChange } from './diff-state.js';
|
|
3
4
|
import { type ResourceStateProvider } from './resource-state.js';
|
|
4
5
|
import { WpClient } from './wp-client.js';
|
|
@@ -6,7 +7,10 @@ export type ResourceDiffJson = {
|
|
|
6
7
|
added: string[];
|
|
7
8
|
changed: StateChange[];
|
|
8
9
|
error?: string;
|
|
10
|
+
heldBack?: string[];
|
|
11
|
+
reason?: string;
|
|
9
12
|
removed: string[];
|
|
13
|
+
status?: 'skipped';
|
|
10
14
|
};
|
|
11
15
|
export type DiffJson = {
|
|
12
16
|
drift: boolean;
|
|
@@ -15,11 +19,18 @@ export type DiffJson = {
|
|
|
15
19
|
right: string;
|
|
16
20
|
};
|
|
17
21
|
export type DiffTarget = {
|
|
22
|
+
heldBackFrom?: (() => Promise<ResourceState>) | undefined;
|
|
23
|
+
isHeldBack?: ((id: string) => boolean) | undefined;
|
|
18
24
|
left: () => Promise<ResourceState>;
|
|
19
25
|
resource: string;
|
|
20
26
|
right: () => Promise<ResourceState>;
|
|
21
27
|
skip?: boolean;
|
|
22
28
|
title: string;
|
|
29
|
+
unsupported?: {
|
|
30
|
+
fix?: string | undefined;
|
|
31
|
+
missing: MissingIntegration;
|
|
32
|
+
reason: string;
|
|
33
|
+
};
|
|
23
34
|
};
|
|
24
35
|
export type DiffSides = {
|
|
25
36
|
againstWp: undefined | WpClient;
|
|
@@ -32,15 +43,19 @@ export declare abstract class DiffCommand extends LoopressCommand {
|
|
|
32
43
|
static againstFlag: {
|
|
33
44
|
against: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
34
45
|
};
|
|
46
|
+
static honorsExclude: boolean;
|
|
35
47
|
protected composerTarget(sides: DiffSides): DiffTarget;
|
|
36
48
|
protected providerTarget(provider: ResourceStateProvider, sides: DiffSides, dirOverride?: string): DiffTarget;
|
|
37
49
|
protected report(targets: DiffTarget[], sides: DiffSides): Promise<DiffJson>;
|
|
50
|
+
protected reportDrift(result: DiffJson): Promise<void>;
|
|
38
51
|
protected resolveSides(against: string | undefined): DiffSides;
|
|
39
52
|
abstract run(): Promise<DiffJson>;
|
|
40
53
|
private computeResource;
|
|
41
54
|
private out;
|
|
42
55
|
private renderFailure;
|
|
56
|
+
private renderHeldBack;
|
|
43
57
|
private renderSection;
|
|
58
|
+
private renderSkipped;
|
|
44
59
|
private wpForEnvironment;
|
|
45
60
|
}
|
|
46
61
|
export declare function resourceDiffCommand(resource: string, options: {
|
|
@@ -48,3 +63,5 @@ export declare function resourceDiffCommand(resource: string, options: {
|
|
|
48
63
|
description: string;
|
|
49
64
|
pathNoun: string;
|
|
50
65
|
}): typeof DiffCommand;
|
|
66
|
+
export declare function excludedFileFor(dir: string, id: string, provider: Pick<ResourceStateProvider, 'fileExtension' | 'fileName'>): boolean;
|
|
67
|
+
export declare function sharedReads(wp: WpClient): WpClient;
|
package/dist/lib/diff-command.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { Args, Flags, ux } from '@oclif/core';
|
|
2
|
-
import
|
|
2
|
+
import got from 'got';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
import { authManager } from '../config/auth.manager.js';
|
|
3
6
|
import { configManager } from '../config/project-config.manager.js';
|
|
4
7
|
import { resolveResourceDir } from '../utils/resource-dirs.js';
|
|
8
|
+
import { API_URL } from './api-client.js';
|
|
5
9
|
import { LoopressCommand } from './base.js';
|
|
10
|
+
import {} from './capabilities.js';
|
|
6
11
|
import { compareStates, isEmptyDiff } from './diff-state.js';
|
|
12
|
+
import { hasExclusions, isExcluded, withoutExclusions } from './exclude.js';
|
|
7
13
|
import { errorMessage } from './narrow.js';
|
|
8
14
|
import { composerLocalState, composerRemoteState, getResourceStateProvider } from './resource-state.js';
|
|
9
15
|
import { WpClient } from './wp-client.js';
|
|
@@ -18,6 +24,7 @@ export class DiffCommand extends LoopressCommand {
|
|
|
18
24
|
description: 'Compare the primary environment against this second environment instead of against local files',
|
|
19
25
|
}),
|
|
20
26
|
};
|
|
27
|
+
static honorsExclude = true;
|
|
21
28
|
composerTarget(sides) {
|
|
22
29
|
// resolve(), not join(): an absolute `rootDir` in loopress.json must stay absolute rather
|
|
23
30
|
// than being prefixed with the cwd, so Composer is read from the same place as the
|
|
@@ -39,10 +46,18 @@ export class DiffCommand extends LoopressCommand {
|
|
|
39
46
|
}
|
|
40
47
|
const dir = resolveResourceDir(provider.dirKind, this.localConfig, dirOverride);
|
|
41
48
|
const { againstWp } = sides;
|
|
49
|
+
const holdsBack = againstWp === undefined && hasExclusions();
|
|
50
|
+
// With exclusions, the local side may read the site too (an excluded file keeps the site's
|
|
51
|
+
// copy): both sides then share one response, so a site change between two reads can't show
|
|
52
|
+
// up as drift.
|
|
53
|
+
const wp = holdsBack ? sharedReads(this.wp) : this.wp;
|
|
42
54
|
return {
|
|
43
|
-
|
|
55
|
+
// Only against local files: they are what an exclude glob names.
|
|
56
|
+
heldBackFrom: holdsBack ? async () => withoutExclusions(async () => provider.local(dir, () => { }, wp)) : undefined,
|
|
57
|
+
isHeldBack: holdsBack ? (id) => excludedFileFor(dir, id, provider) : undefined,
|
|
58
|
+
left: async () => provider.remote(wp, sides.warn, dir),
|
|
44
59
|
resource: provider.resource,
|
|
45
|
-
right: againstWp ? async () => provider.remote(againstWp, sides.warn, dir) : async () => provider.local(dir, sides.warn,
|
|
60
|
+
right: againstWp ? async () => provider.remote(againstWp, sides.warn, dir) : async () => provider.local(dir, sides.warn, wp),
|
|
46
61
|
title: provider.title,
|
|
47
62
|
};
|
|
48
63
|
}
|
|
@@ -84,20 +99,49 @@ export class DiffCommand extends LoopressCommand {
|
|
|
84
99
|
if (result.drift)
|
|
85
100
|
drift = true;
|
|
86
101
|
}
|
|
102
|
+
this.renderHeldBack(checked, sides.leftLabel);
|
|
87
103
|
const skipped = results.filter((result) => result.skipped);
|
|
88
|
-
|
|
89
|
-
this.out(`${c('bold', result.title)} ${c('dim', 'not configured, skipped')}`);
|
|
104
|
+
const unsupported = this.renderSkipped(skipped, resources);
|
|
90
105
|
this.out(`\n${compared} compared: ${changed} changed, ${added} added, ${removed} removed`);
|
|
91
106
|
if (failed)
|
|
92
107
|
this.out(c('red', 'Some resources could not be compared; this run is inconclusive.'));
|
|
93
108
|
else
|
|
94
109
|
this.out(drift ? c('yellow', 'Drift detected.') : c('green', 'Everything is in sync.'));
|
|
110
|
+
if (unsupported.length > 0)
|
|
111
|
+
this.warn(`Skipped with --skip-unsupported, not compared:\n ${unsupported.join('\n ')}`);
|
|
95
112
|
if (failed)
|
|
96
113
|
process.exitCode = 2;
|
|
97
114
|
else if (drift)
|
|
98
115
|
process.exitCode = 1;
|
|
99
116
|
return { drift, left: sides.leftLabel, resources, right: sides.rightLabel };
|
|
100
117
|
}
|
|
118
|
+
// Best-effort, like `recordDeployment`: when a token is present, sends the console the verdict
|
|
119
|
+
// and the changed items (resource, id, change kind, never contents). Silent on any failure, and
|
|
120
|
+
// it never touches the exit code or the `--json` output.
|
|
121
|
+
async reportDrift(result) {
|
|
122
|
+
const items = Object.entries(result.resources).flatMap(([resource, diff]) => [
|
|
123
|
+
...diff.added.map((id) => ({ change: 'added', id, resource })),
|
|
124
|
+
...diff.removed.map((id) => ({ change: 'removed', id, resource })),
|
|
125
|
+
...diff.changed.map(({ id }) => ({ change: 'changed', id, resource })),
|
|
126
|
+
]);
|
|
127
|
+
const inconclusive = Object.values(result.resources).some((diff) => diff.error !== undefined);
|
|
128
|
+
let status = result.drift ? 'drift' : 'clean';
|
|
129
|
+
if (inconclusive)
|
|
130
|
+
status = 'inconclusive';
|
|
131
|
+
try {
|
|
132
|
+
const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
|
|
133
|
+
if (!token)
|
|
134
|
+
return;
|
|
135
|
+
await got.post(`${API_URL}/drift-reports`, {
|
|
136
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
137
|
+
json: { exitCode: process.exitCode ?? 0, items, status, url: this.siteConfig.url },
|
|
138
|
+
timeout: { request: 3000 },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// non-blocking: reporting must never change the diff's outcome
|
|
143
|
+
}
|
|
144
|
+
}
|
|
101
145
|
resolveSides(against) {
|
|
102
146
|
if (against === this.siteConfig.name) {
|
|
103
147
|
this.error('--against must be a different environment from the one being compared.');
|
|
@@ -117,13 +161,24 @@ export class DiffCommand extends LoopressCommand {
|
|
|
117
161
|
async computeResource(target, sides) {
|
|
118
162
|
const empty = { added: [], changed: [], removed: [] };
|
|
119
163
|
if (target.skip)
|
|
120
|
-
return { drift: false, json: empty, resource: target.resource, skipped: true, title: target.title };
|
|
164
|
+
return { drift: false, json: empty, resource: target.resource, skipped: true, title: target.title, unsupported: target.unsupported };
|
|
121
165
|
try {
|
|
122
|
-
const [leftState, rightState] = await Promise.all([target.left(), target.right()]);
|
|
166
|
+
const [leftState, rightState, fullState] = await Promise.all([target.left(), target.right(), target.heldBackFrom?.()]);
|
|
167
|
+
// Whole items: only in the unfiltered local files, or only on the site under an excluded path.
|
|
168
|
+
const heldBackItems = [
|
|
169
|
+
...(fullState ? [...fullState.keys()].filter((id) => !rightState.has(id)) : []),
|
|
170
|
+
...[...leftState.keys()].filter((id) => !rightState.has(id) && !fullState?.has(id) && target.isHeldBack?.(id) === true),
|
|
171
|
+
];
|
|
172
|
+
for (const id of heldBackItems)
|
|
173
|
+
leftState.delete(id);
|
|
174
|
+
// An item an excluded file is part of (a block's view.js): compared with the site's copy of
|
|
175
|
+
// that file, held back when the local one differs.
|
|
176
|
+
const heldBackFiles = fullState ? [...rightState.keys()].filter((id) => fullState.has(id) && !isDeepStrictEqual(fullState.get(id), rightState.get(id))) : [];
|
|
177
|
+
const heldBack = [...heldBackItems, ...heldBackFiles].toSorted((a, b) => a.localeCompare(b));
|
|
123
178
|
const stateDiff = compareStates(leftState, rightState, { left: sides.leftLabel, right: sides.rightLabel });
|
|
124
179
|
return {
|
|
125
180
|
drift: !isEmptyDiff(stateDiff),
|
|
126
|
-
json: { added: stateDiff.added, changed: stateDiff.changed, removed: stateDiff.removed },
|
|
181
|
+
json: { added: stateDiff.added, changed: stateDiff.changed, removed: stateDiff.removed, ...(heldBack.length > 0 && { heldBack }) },
|
|
127
182
|
resource: target.resource,
|
|
128
183
|
stateDiff,
|
|
129
184
|
title: target.title,
|
|
@@ -145,6 +200,16 @@ export class DiffCommand extends LoopressCommand {
|
|
|
145
200
|
const errorLabel = c('red', `error: ${error}`);
|
|
146
201
|
this.out(`${c('bold', title)} ${errorLabel}`);
|
|
147
202
|
}
|
|
203
|
+
// Its own group, after the comparison: excluded items are never drift, but a feature held back
|
|
204
|
+
// from an environment must stay visible there.
|
|
205
|
+
renderHeldBack(results, envName) {
|
|
206
|
+
const held = results.filter((result) => (result.json.heldBack ?? []).length > 0);
|
|
207
|
+
if (held.length === 0)
|
|
208
|
+
return;
|
|
209
|
+
this.out(c('bold', `\nHeld back from ${envName} (exclude in loopress.json), not compared`));
|
|
210
|
+
for (const result of held)
|
|
211
|
+
this.out(c('dim', ` ${result.title}: ${(result.json.heldBack ?? []).join(', ')}`));
|
|
212
|
+
}
|
|
148
213
|
renderSection(title, stateDiff) {
|
|
149
214
|
if (isEmptyDiff(stateDiff)) {
|
|
150
215
|
this.out(`${c('bold', title)} ${c('dim', 'in sync')}`);
|
|
@@ -160,6 +225,24 @@ export class DiffCommand extends LoopressCommand {
|
|
|
160
225
|
this.out(indentPatch(change.patch));
|
|
161
226
|
}
|
|
162
227
|
}
|
|
228
|
+
// The skipped resources' lines. Returns the --skip-unsupported ones for the final warning, and
|
|
229
|
+
// adds them to the JSON result as skipped, with their reason.
|
|
230
|
+
renderSkipped(skipped, resources) {
|
|
231
|
+
const warnings = [];
|
|
232
|
+
for (const { resource, title, unsupported } of skipped) {
|
|
233
|
+
if (!unsupported) {
|
|
234
|
+
this.out(`${c('bold', title)} ${c('dim', 'not configured, skipped')}`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const { fix, reason } = unsupported;
|
|
238
|
+
resources[resource] = { added: [], changed: [], reason, removed: [], status: 'skipped' };
|
|
239
|
+
const note = `${reason}, skipped (--skip-unsupported)`;
|
|
240
|
+
this.out(`${c('bold', title)} ${c('dim', note)}`);
|
|
241
|
+
const fixSentence = fix ? ` ${fix}.` : '';
|
|
242
|
+
warnings.push(`${title}: ${reason}.${fixSentence}`);
|
|
243
|
+
}
|
|
244
|
+
return warnings;
|
|
245
|
+
}
|
|
163
246
|
wpForEnvironment(envName) {
|
|
164
247
|
const env = configManager.getEnvironment(this.projectId, envName);
|
|
165
248
|
if (!env) {
|
|
@@ -173,7 +256,7 @@ export class DiffCommand extends LoopressCommand {
|
|
|
173
256
|
}
|
|
174
257
|
// Builds the `lps <resource> diff` command class for one directory-backed resource. They differ
|
|
175
258
|
// only in wording and which provider they target, so the whole body lives here.
|
|
176
|
-
// `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-
|
|
259
|
+
// `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-style).
|
|
177
260
|
export function resourceDiffCommand(resource, options) {
|
|
178
261
|
const command = options.command ?? resource;
|
|
179
262
|
class ResourceDiff extends DiffCommand {
|
|
@@ -196,6 +279,19 @@ export function resourceDiffCommand(resource, options) {
|
|
|
196
279
|
}
|
|
197
280
|
return ResourceDiff;
|
|
198
281
|
}
|
|
282
|
+
// The id is the file's path under the resource directory for most resources (a slug, an API
|
|
283
|
+
// route), without its extension: a provider whose ids don't follow that names the file itself
|
|
284
|
+
// (`fileName`), one with an extension is tried with it only, and one without (a block folder)
|
|
285
|
+
// with the bare path and every extension resource files use.
|
|
286
|
+
const RESOURCE_FILE_EXTENSIONS = ['.json', '.php', '.html'];
|
|
287
|
+
export function excludedFileFor(dir, id, provider) {
|
|
288
|
+
if (provider.fileName) {
|
|
289
|
+
const fileName = provider.fileName(id);
|
|
290
|
+
return fileName !== undefined && isExcluded(join(dir, fileName));
|
|
291
|
+
}
|
|
292
|
+
const extensions = provider.fileExtension === undefined ? ['', ...RESOURCE_FILE_EXTENSIONS] : [provider.fileExtension];
|
|
293
|
+
return extensions.some((extension) => isExcluded(join(dir, `${id}${extension}`)));
|
|
294
|
+
}
|
|
199
295
|
// Indents an already-formatted change block (a per-field list or a header-less unified diff)
|
|
200
296
|
// so it sits visually under the `~ id` line.
|
|
201
297
|
function indentPatch(patch) {
|
|
@@ -211,3 +307,23 @@ function sharedFailure(errors) {
|
|
|
211
307
|
const distinct = new Set(failures);
|
|
212
308
|
return failures.length > 1 && distinct.size === 1 ? failures[0] : undefined;
|
|
213
309
|
}
|
|
310
|
+
// A client whose plain GETs are read once per path and shared, for the lifetime of one
|
|
311
|
+
// resource comparison. Every other call goes to `wp` unchanged.
|
|
312
|
+
export function sharedReads(wp) {
|
|
313
|
+
const reads = new Map();
|
|
314
|
+
// `Object.create` keeps every other WpClient method, bound to `wp`'s own state.
|
|
315
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above
|
|
316
|
+
const shared = Object.create(wp);
|
|
317
|
+
shared.get = async (path, options) => {
|
|
318
|
+
if (options !== undefined)
|
|
319
|
+
return wp.get(path, options);
|
|
320
|
+
let read = reads.get(path);
|
|
321
|
+
if (read === undefined) {
|
|
322
|
+
read = wp.get(path);
|
|
323
|
+
reads.set(path, read);
|
|
324
|
+
}
|
|
325
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the same path yields the same T
|
|
326
|
+
return read;
|
|
327
|
+
};
|
|
328
|
+
return shared;
|
|
329
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type EnvironmentConfig } from '../types/config.js';
|
|
2
|
+
export type ScannedItem = {
|
|
3
|
+
id: string;
|
|
4
|
+
value: unknown;
|
|
5
|
+
};
|
|
6
|
+
export declare function environmentUrlWarnings(items: ScannedItem[], target: Pick<EnvironmentConfig, 'name' | 'url'>, environments: Array<Pick<EnvironmentConfig, 'name' | 'url'>>): string[];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {} from '../types/config.js';
|
|
2
|
+
// `http(s)://` then the host, plain or JSON-escaped (`https:\/\/`), as block attributes and option
|
|
3
|
+
// values can hold either.
|
|
4
|
+
const ABSOLUTE_URL = /https?:(?:\/\/|\\\/\\\/)[^\s"'<>()\\]+/gi;
|
|
5
|
+
// Warns, never rewrites: an absolute URL whose host is another environment of the project keeps
|
|
6
|
+
// pointing there once pushed (a staging link on production). A relative path, a link to the
|
|
7
|
+
// target environment itself and an external link are left alone.
|
|
8
|
+
export function environmentUrlWarnings(items, target, environments) {
|
|
9
|
+
const others = otherEnvironmentHosts(target, environments);
|
|
10
|
+
if (others.size === 0)
|
|
11
|
+
return [];
|
|
12
|
+
return items.flatMap((item) => [...urlsByHost(item.value, others)].map(([host, urls]) => `${item.id} links to the "${others.get(host)}" environment (${host}), it will still point there on ${target.name}: ${[...urls].join(', ')}. ` +
|
|
13
|
+
'Use a relative path (/about) instead.'));
|
|
14
|
+
}
|
|
15
|
+
// Host (with its port, so two local sites on one machine stay apart) to environment name.
|
|
16
|
+
function otherEnvironmentHosts(target, environments) {
|
|
17
|
+
const targetHost = hostOf(target.url);
|
|
18
|
+
const others = new Map();
|
|
19
|
+
for (const env of environments) {
|
|
20
|
+
const host = hostOf(env.url);
|
|
21
|
+
if (host !== undefined && host !== targetHost && !others.has(host))
|
|
22
|
+
others.set(host, env.name);
|
|
23
|
+
}
|
|
24
|
+
return others;
|
|
25
|
+
}
|
|
26
|
+
function urlsByHost(value, hosts) {
|
|
27
|
+
const found = new Map();
|
|
28
|
+
for (const text of strings(value)) {
|
|
29
|
+
for (const match of text.matchAll(ABSOLUTE_URL)) {
|
|
30
|
+
const url = match[0].replaceAll(String.raw `\/`, '/');
|
|
31
|
+
const host = hostOf(url);
|
|
32
|
+
if (host === undefined || !hosts.has(host))
|
|
33
|
+
continue;
|
|
34
|
+
found.set(host, (found.get(host) ?? new Set()).add(url));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
function hostOf(url) {
|
|
40
|
+
try {
|
|
41
|
+
return new URL(url).host.toLowerCase() || undefined;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function* strings(value) {
|
|
48
|
+
if (typeof value === 'string') {
|
|
49
|
+
yield value;
|
|
50
|
+
}
|
|
51
|
+
else if (Array.isArray(value)) {
|
|
52
|
+
for (const entry of value)
|
|
53
|
+
yield* strings(entry);
|
|
54
|
+
}
|
|
55
|
+
else if (value !== null && typeof value === 'object') {
|
|
56
|
+
for (const entry of Object.values(value))
|
|
57
|
+
yield* strings(entry);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type LoopressLocalConfig } from '../utils/loopress-config.js';
|
|
2
|
+
export declare function environmentExcludes(config: LoopressLocalConfig, envName: string): string[];
|
|
3
|
+
export declare function activateExclusions(options: {
|
|
4
|
+
config: LoopressLocalConfig;
|
|
5
|
+
cwd: string;
|
|
6
|
+
envName: string;
|
|
7
|
+
honors: boolean;
|
|
8
|
+
}): void;
|
|
9
|
+
export declare function disableExclusions(): void;
|
|
10
|
+
export declare function hasExclusions(): boolean;
|
|
11
|
+
export declare function isExcluded(path: string): boolean;
|
|
12
|
+
export declare function withoutExcluded<T>(dir: string, entries: T[], name: (entry: T) => string): T[];
|
|
13
|
+
export declare function heldBackPaths(): string[];
|
|
14
|
+
export declare function withoutExclusions<T>(fn: () => Promise<T>): Promise<T>;
|
|
15
|
+
export declare function matchesAny(matchers: RegExp[], segments: string[]): boolean;
|
|
16
|
+
export declare function globToRegExp(glob: string): RegExp;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import {} from '../utils/loopress-config.js';
|
|
4
|
+
let active;
|
|
5
|
+
// `lps dev` runs the per-resource pushes itself: it disables exclusions for its whole process.
|
|
6
|
+
let disabled = false;
|
|
7
|
+
// Paths (relative to rootDir) the listing layer hid since the active set was installed, for a
|
|
8
|
+
// dry run to list them.
|
|
9
|
+
const heldBack = new Set();
|
|
10
|
+
// Inside `withoutExclusions`: every file is listed, to tell which items a filter held back.
|
|
11
|
+
const suspended = new AsyncLocalStorage();
|
|
12
|
+
export function environmentExcludes(config, envName) {
|
|
13
|
+
const excludes = config.environments?.[envName]?.exclude;
|
|
14
|
+
return Array.isArray(excludes) ? excludes.filter((pattern) => typeof pattern === 'string' && pattern.trim() !== '') : [];
|
|
15
|
+
}
|
|
16
|
+
// Called by every LoopressCommand's init: installs the environment's exclusions when the command
|
|
17
|
+
// honors them, clears whatever a previous command of the same process installed otherwise.
|
|
18
|
+
export function activateExclusions(options) {
|
|
19
|
+
heldBack.clear();
|
|
20
|
+
const patterns = !disabled && options.honors ? environmentExcludes(options.config, options.envName) : [];
|
|
21
|
+
active =
|
|
22
|
+
patterns.length === 0
|
|
23
|
+
? undefined
|
|
24
|
+
: { matchers: patterns.map((pattern) => globToRegExp(pattern)), root: resolve(options.cwd, options.config.rootDir ?? '.') };
|
|
25
|
+
}
|
|
26
|
+
export function disableExclusions() {
|
|
27
|
+
disabled = true;
|
|
28
|
+
active = undefined;
|
|
29
|
+
}
|
|
30
|
+
export function hasExclusions() {
|
|
31
|
+
return active !== undefined;
|
|
32
|
+
}
|
|
33
|
+
// `path` is a file or a directory, absolute or relative to the cwd. A glob matching one of its
|
|
34
|
+
// parent directories covers it too (`hooks/feature` excludes everything under it).
|
|
35
|
+
export function isExcluded(path) {
|
|
36
|
+
if (!active || suspended.getStore())
|
|
37
|
+
return false;
|
|
38
|
+
const rel = relative(active.root, resolve(path));
|
|
39
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
|
|
40
|
+
return false;
|
|
41
|
+
return matchesAny(active.matchers, rel.split(sep));
|
|
42
|
+
}
|
|
43
|
+
// Drops the excluded entries of a listing of `dir` (entries relative to it) and records them.
|
|
44
|
+
export function withoutExcluded(dir, entries, name) {
|
|
45
|
+
if (!active || suspended.getStore())
|
|
46
|
+
return entries;
|
|
47
|
+
const { root } = active;
|
|
48
|
+
return entries.filter((entry) => {
|
|
49
|
+
const path = resolve(dir, name(entry));
|
|
50
|
+
if (!isExcluded(path))
|
|
51
|
+
return true;
|
|
52
|
+
heldBack.add(relative(root, path).split(sep).join('/'));
|
|
53
|
+
return false;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export function heldBackPaths() {
|
|
57
|
+
return [...heldBack].toSorted((a, b) => a.localeCompare(b));
|
|
58
|
+
}
|
|
59
|
+
// Runs `fn` with every file listed, excluded ones included.
|
|
60
|
+
export async function withoutExclusions(fn) {
|
|
61
|
+
return suspended.run(true, fn);
|
|
62
|
+
}
|
|
63
|
+
// Every pattern matched against a '/'-split relative path, or any of its parent directories.
|
|
64
|
+
export function matchesAny(matchers, segments) {
|
|
65
|
+
for (let { length } = segments; length > 0; length--) {
|
|
66
|
+
const candidate = segments.slice(0, length).join('/');
|
|
67
|
+
if (matchers.some((matcher) => matcher.test(candidate)))
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
// `**` any number of directories, `*` anything but '/', `?` one character but '/'. A leading
|
|
73
|
+
// `./` is ignored, the rest is literal.
|
|
74
|
+
export function globToRegExp(glob) {
|
|
75
|
+
let source = glob.trim().replace(/^\.\//, '');
|
|
76
|
+
while (source.endsWith('/'))
|
|
77
|
+
source = source.slice(0, -1);
|
|
78
|
+
let pattern = '';
|
|
79
|
+
for (let i = 0; i < source.length; i++) {
|
|
80
|
+
const char = source.charAt(i);
|
|
81
|
+
if (char === '*' && source.charAt(i + 1) === '*') {
|
|
82
|
+
i += 1;
|
|
83
|
+
if (source.charAt(i + 1) === '/') {
|
|
84
|
+
i += 1;
|
|
85
|
+
pattern += '(?:.*/)?';
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
pattern += '.*';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else if (char === '*') {
|
|
92
|
+
pattern += '[^/]*';
|
|
93
|
+
}
|
|
94
|
+
else if (char === '?') {
|
|
95
|
+
pattern += '[^/]';
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
pattern += char.replaceAll(/[$()+.[\\\]^{|}]/g, String.raw `\$&`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return new RegExp(`^${pattern}$`);
|
|
102
|
+
}
|
|
@@ -2,6 +2,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { basename, extname, join } from 'node:path';
|
|
3
3
|
import {} from '../types/snippet.js';
|
|
4
4
|
import { defaultLocationForType, parseInsertMethod, parseLocation, parseType, } from '../utils/snippet-format.js';
|
|
5
|
+
import { withoutExcluded } from './exclude.js';
|
|
5
6
|
import { errorCode, errorMessage, isRecord } from './narrow.js';
|
|
6
7
|
const TYPE_BY_EXTENSION = {
|
|
7
8
|
'.css': 'css',
|
|
@@ -75,7 +76,7 @@ async function readSidecar(metaPath) {
|
|
|
75
76
|
export async function loadSnippets(path, onSkip) {
|
|
76
77
|
let files;
|
|
77
78
|
try {
|
|
78
|
-
files = await readdir(path);
|
|
79
|
+
files = withoutExcluded(path, await readdir(path), (file) => file);
|
|
79
80
|
}
|
|
80
81
|
catch (error) {
|
|
81
82
|
throw new Error(`Error loading snippets: ${errorMessage(error)}`, { cause: error });
|
|
@@ -11,6 +11,7 @@ import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dir
|
|
|
11
11
|
import { ApiClient } from './api-client.js';
|
|
12
12
|
import { LoopressCommand } from './base.js';
|
|
13
13
|
import {} from './diff-state.js';
|
|
14
|
+
import { isExcluded } from './exclude.js';
|
|
14
15
|
import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
|
|
15
16
|
import { isInteractive } from './interactive.js';
|
|
16
17
|
import { loadFiles as loadDirectoryFiles } from './load-files.js';
|
|
@@ -119,7 +120,7 @@ export function resourcePushCommand(spec) {
|
|
|
119
120
|
// confirmation), but their removal itself happens inside the same atomic swap as the push
|
|
120
121
|
// below, not as separate DELETE calls, so a mid-push failure can't remove server-side
|
|
121
122
|
// files without also landing the new ones.
|
|
122
|
-
const pruneList = flags.prune ? await this.resolvePruneList(new Set(files.map((file) => file.filename))) : [];
|
|
123
|
+
const pruneList = flags.prune ? await this.resolvePruneList(new Set(files.map((file) => file.filename)), path) : [];
|
|
123
124
|
if (this.dryRun) {
|
|
124
125
|
await this.writeAfterPushSnapshot(provider, path, beforeState);
|
|
125
126
|
return { changes: this.previewChanges, pruned: pruneList, pushed: files.map((file) => file.filename), status: 'dry-run' };
|
|
@@ -181,9 +182,10 @@ export function resourcePushCommand(spec) {
|
|
|
181
182
|
// local path in CI must not silently wipe production routes, and unlike `pull`'s local
|
|
182
183
|
// deletions these are not recoverable from the repo. Only decides *which* filenames to
|
|
183
184
|
// prune; pushBatch() is what actually removes them, atomically alongside the push.
|
|
184
|
-
|
|
185
|
+
// A file excluded from this environment (loopress.json) is left alone, never pruned.
|
|
186
|
+
async resolvePruneList(localFilenames, path) {
|
|
185
187
|
const remote = await this.wp.get(spec.endpoint);
|
|
186
|
-
const orphans = remote.map((file) => file.filename).filter((name) => !localFilenames.has(name));
|
|
188
|
+
const orphans = remote.map((file) => file.filename).filter((name) => !localFilenames.has(name) && !isExcluded(join(path, `${name}.php`)));
|
|
187
189
|
if (orphans.length === 0)
|
|
188
190
|
return [];
|
|
189
191
|
const summary = `${pluralize(orphans.length, spec.noun)} on ${this.siteConfig.url} not present locally: ${orphans.join(', ')}`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LoopressCommand } from './base.js';
|
|
2
2
|
import { type ResourceState } from './diff-state.js';
|
|
3
|
+
import { type ScannedItem } from './environment-urls.js';
|
|
3
4
|
import { type ResourceStateProvider } from './resource-state.js';
|
|
4
5
|
export type PushChange = {
|
|
5
6
|
id: string;
|
|
@@ -19,6 +20,7 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
19
20
|
'production-confirmed': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
20
21
|
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
21
22
|
};
|
|
23
|
+
static honorsExclude: boolean;
|
|
22
24
|
protected failedCount: number;
|
|
23
25
|
protected previewChanges: PushChange[];
|
|
24
26
|
protected previewStates?: {
|
|
@@ -28,6 +30,7 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
28
30
|
private refusedByGuard;
|
|
29
31
|
protected captureBeforePushState(provider: ResourceStateProvider, dir: string, writes?: (id: string) => boolean): Promise<ResourceState | undefined>;
|
|
30
32
|
catch(err: Error): Promise<void>;
|
|
33
|
+
finally(error: Error | undefined): Promise<void>;
|
|
31
34
|
protected guardProductionPush(): Promise<void>;
|
|
32
35
|
init(): Promise<void>;
|
|
33
36
|
protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<ResourceState>;
|
|
@@ -40,6 +43,7 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
40
43
|
protected runPushTasks<T>(items: T[], label: (item: T) => string, task: (item: T, task?: {
|
|
41
44
|
output: string;
|
|
42
45
|
}) => Promise<void>): Promise<void>;
|
|
46
|
+
protected warnEnvironmentUrls(items: ScannedItem[]): void;
|
|
43
47
|
protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
|
|
44
48
|
private guardOutsideEdits;
|
|
45
49
|
}
|
package/dist/lib/push-command.js
CHANGED
|
@@ -2,6 +2,7 @@ import { confirm } from '@inquirer/prompts';
|
|
|
2
2
|
import { Flags } from '@oclif/core';
|
|
3
3
|
import got from 'got';
|
|
4
4
|
import { Listr } from 'listr2';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
5
6
|
import { resolve } from 'node:path';
|
|
6
7
|
import { isDeepStrictEqual } from 'node:util';
|
|
7
8
|
import { authManager } from '../config/auth.manager.js';
|
|
@@ -9,12 +10,29 @@ import {} from '../types/config.js';
|
|
|
9
10
|
import { API_URL } from './api-client.js';
|
|
10
11
|
import { LoopressCommand } from './base.js';
|
|
11
12
|
import {} from './diff-state.js';
|
|
13
|
+
import { environmentUrlWarnings } from './environment-urls.js';
|
|
14
|
+
import { heldBackPaths } from './exclude.js';
|
|
12
15
|
import { guardProductionPush } from './guard-production-push.js';
|
|
13
16
|
import { isInteractive } from './interactive.js';
|
|
14
17
|
import { errorMessage } from './narrow.js';
|
|
15
18
|
import {} from './resource-state.js';
|
|
16
19
|
import { readSnapshot, recordToState, writeSnapshot } from './snapshot-store.js';
|
|
17
20
|
import { withoutSpinner, withSpinner } from './spinner.js';
|
|
21
|
+
// The Git state the deployment record carries: the HEAD commit and whether the working tree has
|
|
22
|
+
// uncommitted changes. Empty outside a Git repository (or without git), so nothing is sent then.
|
|
23
|
+
function gitState() {
|
|
24
|
+
try {
|
|
25
|
+
// eslint-disable-next-line sonarjs/no-os-command-from-path -- git is resolved from PATH like in every dev tool
|
|
26
|
+
const git = (args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
|
|
27
|
+
const commit = git(['rev-parse', 'HEAD']);
|
|
28
|
+
if (!commit)
|
|
29
|
+
return {};
|
|
30
|
+
return { commit, dirty: git(['status', '--porcelain', '--untracked-files=normal']) !== '' };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
18
36
|
export function pushChanges(local, remote, options = {}) {
|
|
19
37
|
const changes = [...local.keys()]
|
|
20
38
|
.filter((id) => options.writes?.(id) ?? true)
|
|
@@ -44,6 +62,7 @@ export class PushCommand extends LoopressCommand {
|
|
|
44
62
|
}),
|
|
45
63
|
'production-confirmed': Flags.boolean({ hidden: true }),
|
|
46
64
|
};
|
|
65
|
+
static honorsExclude = true;
|
|
47
66
|
failedCount = 0;
|
|
48
67
|
// A dry run's per-item preview, set by captureBeforePushState: what the push would create,
|
|
49
68
|
// change or leave as it is on the site.
|
|
@@ -101,6 +120,16 @@ export class PushCommand extends LoopressCommand {
|
|
|
101
120
|
}
|
|
102
121
|
await super.catch(err);
|
|
103
122
|
}
|
|
123
|
+
// Lists what the environment's `exclude` globs (loopress.json) kept out of this push, so a
|
|
124
|
+
// feature held back from it is never forgotten there.
|
|
125
|
+
async finally(error) {
|
|
126
|
+
const paths = error ? [] : heldBackPaths();
|
|
127
|
+
if (paths.length > 0) {
|
|
128
|
+
const prefix = this.dryRun ? '[dry-run] ' : '';
|
|
129
|
+
this.log(`${prefix}Held back from ${this.siteConfig.name} (exclude in loopress.json): ${paths.join(', ')}`);
|
|
130
|
+
}
|
|
131
|
+
await super.finally(error);
|
|
132
|
+
}
|
|
104
133
|
async guardProductionPush() {
|
|
105
134
|
await guardProductionPush({
|
|
106
135
|
dryRun: this.dryRun,
|
|
@@ -135,7 +164,7 @@ export class PushCommand extends LoopressCommand {
|
|
|
135
164
|
try {
|
|
136
165
|
await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
|
|
137
166
|
headers: { Authorization: `Bearer ${token}` },
|
|
138
|
-
json: { status, url: this.siteConfig.url },
|
|
167
|
+
json: { status, url: this.siteConfig.url, command: this.id, ...gitState() },
|
|
139
168
|
timeout: { request: 3000 },
|
|
140
169
|
}));
|
|
141
170
|
}
|
|
@@ -191,12 +220,19 @@ export class PushCommand extends LoopressCommand {
|
|
|
191
220
|
})), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
|
|
192
221
|
});
|
|
193
222
|
}
|
|
223
|
+
// Absolute URLs to another environment of the project in what this push sends: warned about,
|
|
224
|
+
// never rewritten, on a dry run too.
|
|
225
|
+
warnEnvironmentUrls(items) {
|
|
226
|
+
const prefix = this.dryRun ? '[dry-run] ' : '';
|
|
227
|
+
for (const warning of environmentUrlWarnings(items, this.siteConfig, this.projectEnvironments))
|
|
228
|
+
this.warn(`${prefix}${warning}`);
|
|
229
|
+
}
|
|
194
230
|
// Pairs `beforeState` (from captureBeforePushState, read before this push touched anything)
|
|
195
231
|
// with a fresh remote read taken right after the push, and writes both as one rollback
|
|
196
232
|
// snapshot. Reading the after-state from WordPress itself, rather than assuming it matches
|
|
197
233
|
// the local files that were pushed, is what keeps this correct for every resource's own
|
|
198
234
|
// partial-push semantics: a readonly option skipped by `option push`, a snippet or form
|
|
199
|
-
// that gets a server-assigned id on create, a theme-
|
|
235
|
+
// that gets a server-assigned id on create, a theme-style push that only touches the
|
|
200
236
|
// active stylesheet, an API/hook push that leaves server-side files alone unless `--prune`.
|
|
201
237
|
// `lps <resource> rollback`'s drift check compares the environment's state at rollback time
|
|
202
238
|
// against exactly this after-state, so it has to be what actually landed, not what was sent.
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import {} from 'node:fs';
|
|
2
2
|
import { readdir, stat } from 'node:fs/promises';
|
|
3
|
-
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { withoutExcluded } from './exclude.js';
|
|
4
5
|
import { errorCode } from './narrow.js';
|
|
5
6
|
export async function readdirTolerant(dir, options) {
|
|
6
7
|
try {
|
|
8
|
+
// Excluded paths (lib/exclude.ts) are hidden here, so every resource listing through this
|
|
9
|
+
// helper leaves them out of a push or a diff on the environment that excludes them.
|
|
7
10
|
return options?.withFileTypes
|
|
8
|
-
? await readdir(dir, { recursive: options.recursive, withFileTypes: true })
|
|
9
|
-
: await readdir(dir, { recursive: options?.recursive });
|
|
11
|
+
? withoutExcluded(dir, await readdir(dir, { recursive: options.recursive, withFileTypes: true }), (entry) => resolve(entry.parentPath, entry.name))
|
|
12
|
+
: withoutExcluded(dir, await readdir(dir, { recursive: options?.recursive }), (entry) => entry);
|
|
10
13
|
}
|
|
11
14
|
catch (error) {
|
|
12
15
|
if (errorCode(error) === 'ENOENT')
|