@loopress/cli 0.33.0 → 0.35.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 +236 -207
- 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 +13 -3
- package/dist/commands/form/push.js +3 -2
- package/dist/commands/option/list.js +6 -1
- 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/audit.d.ts +1 -1
- package/dist/commands/plugin/audit.js +45 -23
- package/dist/commands/plugin/outdated.d.ts +22 -0
- package/dist/commands/plugin/outdated.js +67 -0
- package/dist/commands/plugin/push.d.ts +2 -1
- package/dist/commands/plugin/push.js +109 -23
- package/dist/commands/plugin/update.d.ts +36 -0
- package/dist/commands/plugin/update.js +121 -0
- 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 +116 -40
- package/dist/commands/snippet/push.js +3 -2
- 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 +7 -0
- package/dist/lib/base.js +55 -2
- package/dist/lib/capabilities.d.ts +5 -0
- package/dist/lib/capabilities.js +16 -0
- 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 +3 -5
- package/dist/lib/php-files-command.js +5 -3
- package/dist/lib/push-command.d.ts +26 -0
- package/dist/lib/push-command.js +78 -12
- 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 +71 -12
- package/dist/lib/wp-client.d.ts +2 -0
- package/dist/lib/wp-client.js +36 -10
- package/dist/types/plugin.d.ts +2 -1
- package/dist/types/plugin.js +32 -1
- 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/option-format.d.ts +1 -0
- package/dist/utils/option-format.js +14 -1
- package/dist/utils/page-format.js +2 -1
- package/dist/utils/plugins.d.ts +3 -0
- package/dist/utils/plugins.js +6 -1
- package/dist/utils/rename-retry.d.ts +1 -0
- package/dist/utils/rename-retry.js +20 -0
- package/dist/utils/resource-dirs.js +4 -4
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/version.js +21 -0
- package/dist/utils/wporg.d.ts +5 -0
- package/dist/utils/wporg.js +41 -0
- package/oclif.manifest.json +2131 -1150
- package/package.json +14 -14
- package/schemas/project-config.schema.json +21 -4
- package/dist/commands/api/publish.d.ts +0 -9
- package/dist/commands/api/publish.js +0 -45
- package/dist/commands/snippet/publish.d.ts +0 -10
- package/dist/commands/snippet/publish.js +0 -62
- package/dist/lib/resolve-linked-project.d.ts +0 -8
- package/dist/lib/resolve-linked-project.js +0 -27
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { parseComposerJson, wpackagistRequire } from '../utils/composer.js';
|
|
5
|
+
import {} from '../utils/loopress-config.js';
|
|
6
|
+
import { isLocalEnvironment } from './wp-client.js';
|
|
7
|
+
// The wordpress.org plugins the project declares: composer.json's wpackagist plugins when it
|
|
8
|
+
// exists, loopress.json's otherwise. Unreadable or invalid composer.json: none, the commands
|
|
9
|
+
// report it.
|
|
10
|
+
export async function declaredPlugins(options) {
|
|
11
|
+
const path = resolve(process.cwd(), options.rootDir, 'composer.json');
|
|
12
|
+
if (!existsSync(path))
|
|
13
|
+
return { fromComposer: false, plugins: Object.keys(options.localConfig.plugins ?? {}) };
|
|
14
|
+
try {
|
|
15
|
+
const composerJson = parseComposerJson(await readFile(path, 'utf8'));
|
|
16
|
+
return { fromComposer: true, plugins: Object.keys(wpackagistRequire(composerJson, 'plugin', isLocalEnvironment(options.siteName))) };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { fromComposer: true, plugins: [] };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -46,6 +46,9 @@ export function declaredPushCommand(spec, description) {
|
|
|
46
46
|
parse: (raw, filePath) => parseDeclaredFile(spec, filePath, raw),
|
|
47
47
|
});
|
|
48
48
|
this.log(`Found ${pluralize(items.length, spec.noun, spec.plural)} to push`);
|
|
49
|
+
// Patterns carry block markup, the other declared types only registration arguments.
|
|
50
|
+
if (spec.cliName === 'pattern')
|
|
51
|
+
this.warnEnvironmentUrls(items.map((item) => ({ id: `${spec.plural}/${item.slug}`, value: item.args })));
|
|
49
52
|
const pushed = [];
|
|
50
53
|
await this.runPushTasks(items, (item) => item.slug, async (item, task) => {
|
|
51
54
|
await this.pushItem(item, task);
|
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' | 'block' | 'css' | 'hook' | 'page' | 'part' | 'plugin' | 'snippet' | 'template';
|
|
3
3
|
export declare const RESOURCE_TYPES: ResourceType[];
|
|
4
4
|
export type WatchTarget = {
|
|
5
5
|
commandId: string;
|
package/dist/lib/dev-watch.js
CHANGED
|
@@ -2,12 +2,10 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { join, sep } from 'node:path';
|
|
3
3
|
import {} from '../utils/loopress-config.js';
|
|
4
4
|
import { resolveResourceDir } from '../utils/resource-dirs.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
// precedence a combined `--only=a,b --skip=b` reads as: watch a, b, then drop b.
|
|
5
|
+
import { selectResources } from './resource-selection.js';
|
|
6
|
+
export const RESOURCE_TYPES = ['snippet', 'template', 'part', 'css', 'block', 'page', 'api', 'hook', 'plugin'];
|
|
8
7
|
export function resolveResourceTypes(only, skip = []) {
|
|
9
|
-
|
|
10
|
-
return base.filter((type) => !skip.includes(type));
|
|
8
|
+
return selectResources(RESOURCE_TYPES, only, skip);
|
|
11
9
|
}
|
|
12
10
|
// One target per requested type that actually has something to watch: a missing directory (no
|
|
13
11
|
// `api/` folder yet) or an empty plugin manifest is left out silently, matching `push`'s own
|
|
@@ -17,19 +15,19 @@ export function buildWatchTargets(types, localConfig, cwd) {
|
|
|
17
15
|
const candidates = {
|
|
18
16
|
api: { commandId: 'api:push', path: join(cwd, resolveResourceDir('api', localConfig)), type: 'api' },
|
|
19
17
|
// The theme CSS is bundled into the child theme with the templates, by the same push.
|
|
20
|
-
|
|
18
|
+
block: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('blockCss', localConfig)), type: 'block' },
|
|
21
19
|
css: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('css', localConfig)), type: 'css' },
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
hook: { commandId: 'hook:push', path: join(cwd, resolveResourceDir('hooks', localConfig)), type: 'hook' },
|
|
21
|
+
page: { commandId: 'page:push', path: join(cwd, resolveResourceDir('page', localConfig)), type: 'page' },
|
|
22
|
+
plugin: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugin' },
|
|
23
|
+
part: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('part', localConfig)), type: 'part' },
|
|
24
|
+
template: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('template', localConfig)), type: 'template' },
|
|
25
|
+
snippet: { commandId: 'snippet:push', path: join(cwd, resolveResourceDir('snippets', localConfig)), type: 'snippet' },
|
|
28
26
|
};
|
|
29
27
|
return types
|
|
30
28
|
.map((type) => candidates[type])
|
|
31
29
|
.filter((target) => {
|
|
32
|
-
if (target.type === '
|
|
30
|
+
if (target.type === 'plugin')
|
|
33
31
|
return Object.keys(localConfig.plugins ?? {}).length > 0;
|
|
34
32
|
return existsSync(target.path);
|
|
35
33
|
});
|
|
@@ -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',
|
|
@@ -68,14 +69,11 @@ async function readSidecar(metaPath) {
|
|
|
68
69
|
throw new Error('not a JSON object');
|
|
69
70
|
return meta;
|
|
70
71
|
}
|
|
71
|
-
//
|
|
72
|
-
// (publishes to the Loopress api as a shared source): both read the same local `snippets/`
|
|
73
|
-
// directory the same way. `onSkip` is optional so callers that don't care about per-file
|
|
74
|
-
// warnings (e.g. `snippet publish`) can ignore them; skipped files are simply left out either way.
|
|
72
|
+
// Reads the local `snippets/` directory. `onSkip` is optional: skipped files are simply left out either way.
|
|
75
73
|
export async function loadSnippets(path, onSkip) {
|
|
76
74
|
let files;
|
|
77
75
|
try {
|
|
78
|
-
files = await readdir(path);
|
|
76
|
+
files = withoutExcluded(path, await readdir(path), (file) => file);
|
|
79
77
|
}
|
|
80
78
|
catch (error) {
|
|
81
79
|
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(', ')}`;
|