@loopress/cli 0.24.1 → 0.25.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 +629 -88
- package/dist/commands/acf/push.d.ts +1 -0
- package/dist/commands/acf/push.js +25 -2
- package/dist/commands/acf/rollback.d.ts +2 -0
- package/dist/commands/acf/rollback.js +5 -0
- package/dist/commands/api/rollback.d.ts +2 -0
- package/dist/commands/api/rollback.js +5 -0
- package/dist/commands/diff.js +1 -1
- package/dist/commands/doctor.d.ts +17 -1
- package/dist/commands/doctor.js +55 -38
- package/dist/commands/form/pull.js +4 -1
- package/dist/commands/form/push.d.ts +1 -0
- package/dist/commands/form/push.js +29 -3
- package/dist/commands/form/rollback.d.ts +2 -0
- package/dist/commands/form/rollback.js +5 -0
- package/dist/commands/hook/rollback.d.ts +2 -0
- package/dist/commands/hook/rollback.js +5 -0
- package/dist/commands/init.js +1 -0
- package/dist/commands/menu/diff.d.ts +2 -0
- package/dist/commands/menu/diff.js +5 -0
- package/dist/commands/menu/list.d.ts +13 -0
- package/dist/commands/menu/list.js +32 -0
- package/dist/commands/menu/pull.d.ts +15 -0
- package/dist/commands/menu/pull.js +72 -0
- package/dist/commands/menu/push.d.ts +17 -0
- package/dist/commands/menu/push.js +120 -0
- package/dist/commands/menu/rollback.d.ts +2 -0
- package/dist/commands/menu/rollback.js +5 -0
- package/dist/commands/option/add.js +1 -1
- package/dist/commands/option/push.d.ts +1 -0
- package/dist/commands/option/push.js +26 -1
- package/dist/commands/option/rollback.d.ts +2 -0
- package/dist/commands/option/rollback.js +5 -0
- package/dist/commands/plugin/push.d.ts +1 -0
- package/dist/commands/plugin/push.js +14 -0
- package/dist/commands/promote.d.ts +8 -1
- package/dist/commands/promote.js +2 -0
- package/dist/commands/pull.d.ts +11 -1
- package/dist/commands/pull.js +9 -2
- package/dist/commands/push.d.ts +11 -1
- package/dist/commands/push.js +9 -2
- package/dist/commands/seo/pull.js +6 -2
- package/dist/commands/seo/push.d.ts +1 -0
- package/dist/commands/seo/push.js +33 -3
- package/dist/commands/seo/rollback.d.ts +2 -0
- package/dist/commands/seo/rollback.js +5 -0
- package/dist/commands/snippet/push.d.ts +1 -0
- package/dist/commands/snippet/push.js +31 -1
- package/dist/commands/snippet/rollback.d.ts +2 -0
- package/dist/commands/snippet/rollback.js +5 -0
- package/dist/commands/theme-styles/diff.d.ts +2 -0
- package/dist/commands/theme-styles/diff.js +5 -0
- package/dist/commands/theme-styles/pull.d.ts +12 -0
- package/dist/commands/theme-styles/pull.js +31 -0
- package/dist/commands/theme-styles/push.d.ts +12 -0
- package/dist/commands/theme-styles/push.js +45 -0
- package/dist/commands/theme-styles/rollback.d.ts +2 -0
- package/dist/commands/theme-styles/rollback.js +5 -0
- package/dist/lib/app-manifest.js +4 -27
- package/dist/lib/base.d.ts +2 -0
- package/dist/lib/base.js +6 -0
- package/dist/lib/find-orphaned-files.js +5 -47
- package/dist/lib/load-files.js +4 -42
- package/dist/lib/php-files-command.d.ts +1 -0
- package/dist/lib/php-files-command.js +84 -47
- package/dist/lib/push-command.d.ts +4 -0
- package/dist/lib/push-command.js +49 -0
- package/dist/lib/readdir-tolerant.d.ts +1 -0
- package/dist/lib/readdir-tolerant.js +12 -0
- package/dist/lib/resource-state.js +92 -3
- package/dist/lib/rollback-command.d.ts +21 -0
- package/dist/lib/rollback-command.js +180 -0
- package/dist/lib/rollback-materialize.d.ts +1 -0
- package/dist/lib/rollback-materialize.js +121 -0
- package/dist/lib/snapshot-store.d.ts +30 -0
- package/dist/lib/snapshot-store.js +133 -0
- package/dist/lib/validate-local.js +2 -1
- package/dist/types/project-config.generated.d.ts +8 -0
- package/dist/utils/acf-format.d.ts +1 -0
- package/dist/utils/acf-format.js +6 -0
- package/dist/utils/form-format.d.ts +4 -0
- package/dist/utils/form-format.js +3 -7
- package/dist/utils/menu-format.d.ts +24 -0
- package/dist/utils/menu-format.js +8 -0
- package/dist/utils/option-format.d.ts +1 -0
- package/dist/utils/resource-dirs.d.ts +2 -0
- package/dist/utils/resource-dirs.js +4 -0
- package/dist/utils/seo-format.d.ts +8 -0
- package/dist/utils/seo-format.js +3 -0
- package/dist/utils/snippet-format.d.ts +1 -0
- package/dist/utils/snippet-format.js +1 -0
- package/dist/utils/theme-styles-format.d.ts +26 -0
- package/dist/utils/theme-styles-format.js +55 -0
- package/oclif.manifest.json +1736 -465
- package/package.json +4 -2
- package/schemas/project-config.schema.json +10 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { resourceRollbackCommand } from '../../lib/rollback-command.js';
|
|
2
|
+
export default resourceRollbackCommand('snippet', {
|
|
3
|
+
description: 'Restore snippets to a snapshot taken automatically before an earlier `lps snippet push`',
|
|
4
|
+
pathNoun: 'snippets directory',
|
|
5
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { resourceDiffCommand } from '../../lib/diff-command.js';
|
|
2
|
+
export default resourceDiffCommand('theme-styles', {
|
|
3
|
+
description: "Show what differs in the active block theme's Global Styles (Site Editor > Styles) between your local file and a WordPress environment, or between two environments",
|
|
4
|
+
pathNoun: 'theme styles directory',
|
|
5
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
export default class Pull extends LoopressCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
};
|
|
11
|
+
run(): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
5
|
+
import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName, } from '../../utils/theme-styles-format.js';
|
|
6
|
+
export default class Pull extends LoopressCommand {
|
|
7
|
+
static args = {
|
|
8
|
+
path: Args.string({ description: 'Path to theme styles directory (overrides project config)' }),
|
|
9
|
+
};
|
|
10
|
+
static description = "Pull the active block theme's Global Styles customizations (Site Editor > Styles) from WordPress into a local file";
|
|
11
|
+
static examples = ['$ lps theme-styles pull'];
|
|
12
|
+
static flags = {
|
|
13
|
+
...LoopressCommand.dryRunFlag,
|
|
14
|
+
};
|
|
15
|
+
async run() {
|
|
16
|
+
const { args } = await this.parse(Pull);
|
|
17
|
+
const { url } = this.siteConfig;
|
|
18
|
+
const dir = this.resolveThemeStylesPath(args.path);
|
|
19
|
+
this.log(`Pulling Global Styles from ${url}`);
|
|
20
|
+
const { id, stylesheet } = await getActiveThemeGlobalStyles(this.wp);
|
|
21
|
+
const item = await this.wp.get(globalStylesEndpoint(id));
|
|
22
|
+
const file = join(dir, themeStylesFileName(stylesheet));
|
|
23
|
+
if (this.dryRun) {
|
|
24
|
+
this.log(`[dry-run] Would pull Global Styles for "${stylesheet}" to ${file}`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
await mkdir(dir, { recursive: true });
|
|
28
|
+
await writeFile(file, JSON.stringify(canonicalGlobalStyles(item), null, 2) + '\n');
|
|
29
|
+
this.log(`Pulled Global Styles for "${stylesheet}" to ${file}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
export default class Push extends PushCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
};
|
|
11
|
+
run(): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
5
|
+
import { getResourceStateProvider } from '../../lib/resource-state.js';
|
|
6
|
+
import { getActiveThemeGlobalStyles, globalStylesEndpoint, themeStylesFileName } from '../../utils/theme-styles-format.js';
|
|
7
|
+
export default class Push extends PushCommand {
|
|
8
|
+
static args = {
|
|
9
|
+
path: Args.string({ description: 'Path to theme styles directory (overrides project config)' }),
|
|
10
|
+
};
|
|
11
|
+
static description = "Push the local Global Styles file to the active block theme's Site Editor > Styles on WordPress";
|
|
12
|
+
static examples = ['$ lps theme-styles push'];
|
|
13
|
+
static flags = {
|
|
14
|
+
...PushCommand.dryRunFlag,
|
|
15
|
+
};
|
|
16
|
+
async run() {
|
|
17
|
+
const { args } = await this.parse(Push);
|
|
18
|
+
const { url } = this.siteConfig;
|
|
19
|
+
const dir = this.resolveThemeStylesPath(args.path);
|
|
20
|
+
this.log(`Pushing Global Styles to ${url}`);
|
|
21
|
+
const { id, stylesheet } = await getActiveThemeGlobalStyles(this.wp);
|
|
22
|
+
const file = join(dir, themeStylesFileName(stylesheet));
|
|
23
|
+
let raw;
|
|
24
|
+
try {
|
|
25
|
+
raw = await readFile(file, 'utf8');
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code === 'ENOENT') {
|
|
29
|
+
this.error(`No local Global Styles file found for the active theme ("${file}"). Run \`lps theme-styles pull\` first.`);
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
if (this.dryRun) {
|
|
34
|
+
this.log(`[dry-run] Would push: ${file}`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const provider = getResourceStateProvider('theme-styles');
|
|
38
|
+
const beforeState = await this.captureBeforePushState(provider, dir);
|
|
39
|
+
const local = JSON.parse(raw);
|
|
40
|
+
await this.wp.post(globalStylesEndpoint(id), { settings: local.settings ?? {}, styles: local.styles ?? {} });
|
|
41
|
+
this.log(`Pushed: ${file}`);
|
|
42
|
+
await this.writeAfterPushSnapshot(provider, dir, beforeState);
|
|
43
|
+
await this.recordSuccess();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { resourceRollbackCommand } from '../../lib/rollback-command.js';
|
|
2
|
+
export default resourceRollbackCommand('theme-styles', {
|
|
3
|
+
description: 'Restore the active theme’s Global Styles to a snapshot taken automatically before an earlier `lps theme-styles push`',
|
|
4
|
+
pathNoun: 'theme styles directory',
|
|
5
|
+
});
|
package/dist/lib/app-manifest.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import { extname, join
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { extname, join } from 'node:path';
|
|
4
|
+
import { walkFiles } from './readdir-tolerant.js';
|
|
4
5
|
export const APP_CONFIG_FILENAME = 'loopress.app.json';
|
|
5
6
|
const DEFAULT_ASSETS_DIR = 'dist';
|
|
6
7
|
// Static assets a bundler emits, mirrors the server's AppsDirectory::ALLOWED_EXTENSIONS.
|
|
@@ -14,35 +15,11 @@ const ALLOWED_EXTENSIONS = new Set([
|
|
|
14
15
|
export function isAllowedAsset(relPath) {
|
|
15
16
|
return ALLOWED_EXTENSIONS.has(extname(relPath).toLowerCase());
|
|
16
17
|
}
|
|
17
|
-
async function walk(dir, root) {
|
|
18
|
-
let entries;
|
|
19
|
-
try {
|
|
20
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
21
|
-
}
|
|
22
|
-
catch (error) {
|
|
23
|
-
if (error.code === 'ENOENT')
|
|
24
|
-
return [];
|
|
25
|
-
throw error;
|
|
26
|
-
}
|
|
27
|
-
const out = [];
|
|
28
|
-
for (const entry of entries) {
|
|
29
|
-
const abs = join(dir, entry.name);
|
|
30
|
-
if (entry.isDirectory()) {
|
|
31
|
-
out.push(...(await walk(abs, root)));
|
|
32
|
-
}
|
|
33
|
-
else if (entry.isFile()) {
|
|
34
|
-
// Identity is always '/'-joined, never the OS separator: it travels over HTTP and is
|
|
35
|
-
// matched against the server's own '/'-joined paths.
|
|
36
|
-
out.push(relative(root, abs).replaceAll(/[\\/]/g, '/'));
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return out;
|
|
40
|
-
}
|
|
41
18
|
// Builds the sorted file list of a dist directory. Throws on a file whose extension the
|
|
42
19
|
// server would reject, so the push fails locally with a clear message instead of a 400
|
|
43
20
|
// halfway through the upload.
|
|
44
21
|
export async function buildFileList(distDir) {
|
|
45
|
-
const relPaths = (await
|
|
22
|
+
const relPaths = (await walkFiles(distDir)).sort((a, b) => a.localeCompare(b));
|
|
46
23
|
const files = [];
|
|
47
24
|
for (const path of relPaths) {
|
|
48
25
|
if (!isAllowedAsset(path)) {
|
package/dist/lib/base.d.ts
CHANGED
|
@@ -36,9 +36,11 @@ export declare abstract class LoopressCommand extends Command {
|
|
|
36
36
|
protected resolveAppsPath(override?: string): string;
|
|
37
37
|
protected resolveFormPath(override?: string): string;
|
|
38
38
|
protected resolveHooksPath(override?: string): string;
|
|
39
|
+
protected resolveMenuPath(override?: string): string;
|
|
39
40
|
protected resolveOptionsPath(override?: string): string;
|
|
40
41
|
protected resolveSeoPath(override?: string): string;
|
|
41
42
|
protected resolveSnippetsPath(override?: string): string;
|
|
43
|
+
protected resolveThemeStylesPath(override?: string): string;
|
|
42
44
|
private pickEnvironment;
|
|
43
45
|
private resolveEnvironment;
|
|
44
46
|
private resolveEnvironmentForConfiguredProject;
|
package/dist/lib/base.js
CHANGED
|
@@ -147,6 +147,9 @@ export class LoopressCommand extends Command {
|
|
|
147
147
|
resolveHooksPath(override) {
|
|
148
148
|
return resolveResourceDir('hooks', this.localConfig, override);
|
|
149
149
|
}
|
|
150
|
+
resolveMenuPath(override) {
|
|
151
|
+
return resolveResourceDir('menu', this.localConfig, override);
|
|
152
|
+
}
|
|
150
153
|
resolveOptionsPath(override) {
|
|
151
154
|
return resolveResourceDir('options', this.localConfig, override);
|
|
152
155
|
}
|
|
@@ -156,6 +159,9 @@ export class LoopressCommand extends Command {
|
|
|
156
159
|
resolveSnippetsPath(override) {
|
|
157
160
|
return resolveResourceDir('snippets', this.localConfig, override);
|
|
158
161
|
}
|
|
162
|
+
resolveThemeStylesPath(override) {
|
|
163
|
+
return resolveResourceDir('themeStyles', this.localConfig, override);
|
|
164
|
+
}
|
|
159
165
|
pickEnvironment(project, envName) {
|
|
160
166
|
const env = project.environments[envName];
|
|
161
167
|
if (!env) {
|
|
@@ -1,51 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
// Same shape as load-files.ts's own walker, kept separate rather than shared: this one
|
|
4
|
-
// filters against a list of extensions (snippet pull needs more than one), that one
|
|
5
|
-
// against a single extension, and reconciling the two would cost more than the ~15 duplicated
|
|
6
|
-
// lines it would save.
|
|
7
|
-
async function walk(dir, extensions) {
|
|
8
|
-
let entries;
|
|
9
|
-
try {
|
|
10
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
11
|
-
}
|
|
12
|
-
catch (error) {
|
|
13
|
-
if (error.code === 'ENOENT')
|
|
14
|
-
return [];
|
|
15
|
-
throw error;
|
|
16
|
-
}
|
|
17
|
-
const relativePaths = [];
|
|
18
|
-
for (const entry of entries) {
|
|
19
|
-
if (entry.isDirectory()) {
|
|
20
|
-
// join() builds the real OS path to actually walk, but the returned identity is always
|
|
21
|
-
// '/'-joined regardless of platform: it's matched against `keep`, filenames the server
|
|
22
|
-
// sends with '/', never the OS separator. join() here (backslash on Windows) would make
|
|
23
|
-
// every nested file's identity never match, findOrphanedFiles() would then report a
|
|
24
|
-
// live, kept file as orphaned and pull.ts would delete it.
|
|
25
|
-
const nested = await walk(join(dir, entry.name), extensions);
|
|
26
|
-
relativePaths.push(...nested.map((relativePath) => `${entry.name}/${relativePath}`));
|
|
27
|
-
}
|
|
28
|
-
else if (extensions.includes(extname(entry.name))) {
|
|
29
|
-
relativePaths.push(entry.name);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return relativePaths;
|
|
33
|
-
}
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
import { readdirTolerant, walkFiles } from './readdir-tolerant.js';
|
|
34
3
|
export async function findOrphanedFiles(dir, keep, matcher) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
try {
|
|
41
|
-
files = await readdir(dir);
|
|
42
|
-
}
|
|
43
|
-
catch (error) {
|
|
44
|
-
if (error.code === 'ENOENT')
|
|
45
|
-
return [];
|
|
46
|
-
throw error;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
4
|
+
// walkFiles already returns '/'-joined identities regardless of platform: matched against
|
|
5
|
+
// `keep`, filenames the server sends with '/', never the OS separator.
|
|
6
|
+
const files = matcher.recursive ? await walkFiles(dir) : await readdirTolerant(dir);
|
|
49
7
|
return files.filter((file) => {
|
|
50
8
|
const ext = extname(file);
|
|
51
9
|
if (!matcher.extensions.includes(ext))
|
package/dist/lib/load-files.js
CHANGED
|
@@ -1,52 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { extname, join } from 'node:path';
|
|
3
|
-
|
|
4
|
-
// subdirectories, e.g. api/invoice-pdf/[order_id].php); every other resource type stays
|
|
5
|
-
// flat. withFileTypes avoids a second stat() per entry to tell files from directories.
|
|
6
|
-
async function walk(dir, extension) {
|
|
7
|
-
let entries;
|
|
8
|
-
try {
|
|
9
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
10
|
-
}
|
|
11
|
-
catch (error) {
|
|
12
|
-
if (error.code === 'ENOENT')
|
|
13
|
-
return [];
|
|
14
|
-
throw error;
|
|
15
|
-
}
|
|
16
|
-
const filePaths = [];
|
|
17
|
-
for (const entry of entries) {
|
|
18
|
-
const entryPath = join(dir, entry.name);
|
|
19
|
-
if (entry.isDirectory()) {
|
|
20
|
-
filePaths.push(...(await walk(entryPath, extension)));
|
|
21
|
-
}
|
|
22
|
-
else if (extname(entry.name) === extension) {
|
|
23
|
-
filePaths.push(entryPath);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return filePaths;
|
|
27
|
-
}
|
|
3
|
+
import { readdirTolerant, walkFiles } from './readdir-tolerant.js';
|
|
28
4
|
// Loads every `<extension>` file of a directory through `parse`. One file is read in
|
|
29
5
|
// isolation: a corrupted or hand-broken file (unreadable, bad JSON, failed validation) must
|
|
30
6
|
// only skip that file, never abort loading the rest of the directory, so `parse` throwing is
|
|
31
7
|
// reported through `onSkip` and the loop moves on. ENOENT on the directory itself means
|
|
32
8
|
// "nothing to load", not an error.
|
|
33
9
|
export async function loadFiles(dir, options) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
filePaths = await walk(dir, options.extension);
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
let entries;
|
|
40
|
-
try {
|
|
41
|
-
entries = await readdir(dir);
|
|
42
|
-
}
|
|
43
|
-
catch (error) {
|
|
44
|
-
if (error.code === 'ENOENT')
|
|
45
|
-
return [];
|
|
46
|
-
throw error;
|
|
47
|
-
}
|
|
48
|
-
filePaths = entries.filter((entry) => extname(entry) === options.extension).map((entry) => join(dir, entry));
|
|
49
|
-
}
|
|
10
|
+
const entries = options.recursive ? await walkFiles(dir) : await readdirTolerant(dir);
|
|
11
|
+
const filePaths = entries.filter((entry) => extname(entry) === options.extension).map((entry) => join(dir, entry));
|
|
50
12
|
const items = [];
|
|
51
13
|
for (const filePath of filePaths) {
|
|
52
14
|
try {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { confirm } from '@inquirer/prompts';
|
|
2
2
|
import { Args, Flags } from '@oclif/core';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
3
4
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
5
|
import { dirname, join, relative, sep } from 'node:path';
|
|
5
6
|
import { authManager } from '../config/auth.manager.js';
|
|
@@ -11,12 +12,26 @@ import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
|
|
|
11
12
|
import { isInteractive } from './interactive.js';
|
|
12
13
|
import { loadFiles as loadDirectoryFiles } from './load-files.js';
|
|
13
14
|
import { PushCommand } from './push-command.js';
|
|
15
|
+
import { getResourceStateProvider } from './resource-state.js';
|
|
14
16
|
import { isApplicative404 } from './wp-client.js';
|
|
15
17
|
// Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
|
|
16
18
|
// an absent declare(strict_types=1); and one that appears more than once (it needs a single
|
|
17
19
|
// unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
|
|
18
20
|
// presence, so a file that would still fail server-side doesn't falsely pass this check.
|
|
19
21
|
const DECLARE_PATTERN = /declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;/g;
|
|
22
|
+
// A file's expected revision (#234), computed from `beforeState`: the remote listing already
|
|
23
|
+
// read by `captureBeforePushState` right before this push, for the rollback snapshot. Its value
|
|
24
|
+
// for a PHP file is exactly the file's own raw content string (see phpFilesProvider in
|
|
25
|
+
// resource-state.ts), the same string AbstractFilesController::push_file() hashes server-side to
|
|
26
|
+
// produce the `revision` it returns, so hashing it here the same way (sha256, hex) reproduces
|
|
27
|
+
// that server revision with no extra network round trip and no need for the server to echo a
|
|
28
|
+
// `revision` field back through the list endpoint for the CLI to consume. A filename absent from
|
|
29
|
+
// `beforeState` (a new file, or `beforeState` itself unavailable, e.g. a dry run or a failed
|
|
30
|
+
// remote read) has nothing to condition on: pushFile falls back to today's unconditional create.
|
|
31
|
+
function expectedRevisionFor(beforeState, filename) {
|
|
32
|
+
const content = beforeState?.get(filename);
|
|
33
|
+
return typeof content === 'string' ? createHash('sha256').update(content, 'utf8').digest('hex') : undefined;
|
|
34
|
+
}
|
|
20
35
|
function pathArg(pathNoun) {
|
|
21
36
|
return { path: Args.string({ description: `Path to ${pathNoun} (overrides project config)` }) };
|
|
22
37
|
}
|
|
@@ -57,25 +72,34 @@ export function resourcePushCommand(spec) {
|
|
|
57
72
|
const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
|
|
58
73
|
this.log(`Pushing ${spec.label} to ${url}`);
|
|
59
74
|
this.log(`${spec.pathLabel} path: ${path}`);
|
|
75
|
+
const provider = getResourceStateProvider(spec.cliName);
|
|
76
|
+
const beforeState = await this.captureBeforePushState(provider, path);
|
|
60
77
|
const files = await this.loadFiles(path);
|
|
61
78
|
this.log(`Found ${pluralize(files.length, spec.noun)} to push`);
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
// Local, network-free checks only (filename shape, the declare(strict_types=1) guard):
|
|
80
|
+
// catches a bad file before anything is staged on WordPress, same as before #236. The
|
|
81
|
+
// actual write is no longer per-file here, it's the single atomic batch request below.
|
|
64
82
|
await this.runPushTasks(files, (file) => file.filename, async (file, task) => {
|
|
65
|
-
|
|
66
|
-
pushed.push(file.filename);
|
|
67
|
-
if (result?.public)
|
|
68
|
-
publicRoutes.push(file.filename);
|
|
83
|
+
this.validateFileLocally(file, task);
|
|
69
84
|
});
|
|
70
85
|
if (this.failedCount > 0) {
|
|
71
86
|
this.error(`${pluralize(this.failedCount, spec.noun)} failed to push.`);
|
|
72
87
|
}
|
|
73
|
-
|
|
74
|
-
|
|
88
|
+
// Resolved before the batch request: which files to prune is a local decision (a GET plus
|
|
89
|
+
// confirmation), but their removal itself happens inside the same atomic swap as the push
|
|
90
|
+
// below, not as separate DELETE calls, so a mid-push failure can't remove server-side
|
|
91
|
+
// files without also landing the new ones.
|
|
92
|
+
const pruneList = flags.prune ? await this.resolvePruneList(new Set(files.map((file) => file.filename))) : [];
|
|
93
|
+
if (this.dryRun) {
|
|
94
|
+
for (const file of files)
|
|
95
|
+
this.log(`[dry-run] Would push: ${file.filename}`);
|
|
96
|
+
await this.writeAfterPushSnapshot(provider, path, beforeState);
|
|
97
|
+
return { pruned: pruneList, pushed: files.map((file) => file.filename), status: 'dry-run' };
|
|
75
98
|
}
|
|
76
|
-
const pruned =
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
const { pruned, pushed } = files.length === 0 && pruneList.length === 0
|
|
100
|
+
? { pruned: [], pushed: [] }
|
|
101
|
+
: await this.pushBatch(files, pruneList, beforeState);
|
|
102
|
+
await this.writeAfterPushSnapshot(provider, path, beforeState);
|
|
79
103
|
await this.recordSuccess();
|
|
80
104
|
await spec.afterPush?.({ filenames: pushed, siteConfig: this.siteConfig });
|
|
81
105
|
this.log(`All ${spec.label} pushed.`);
|
|
@@ -84,12 +108,52 @@ export function resourcePushCommand(spec) {
|
|
|
84
108
|
async loadFiles(path) {
|
|
85
109
|
return loadPhpFiles(this, path);
|
|
86
110
|
}
|
|
111
|
+
// One request stages every file and every pruned filename, then swaps them all into place
|
|
112
|
+
// together (#236): a failure anywhere in the batch (a stale expectedRevision, a class
|
|
113
|
+
// collision, a syntax error) leaves WordPress completely unchanged, not partially pushed.
|
|
114
|
+
async pushBatch(files, pruneList, beforeState) {
|
|
115
|
+
const body = {
|
|
116
|
+
files: files.map((file) => {
|
|
117
|
+
const entry = { content: file.content, filename: file.filename };
|
|
118
|
+
// `beforeState` was read once, at the start of this push run (see `expectedRevisionFor`
|
|
119
|
+
// above): there is no per-file GET endpoint to re-read it right before this batch the
|
|
120
|
+
// way `option push` does. Sent back as `expectedRevision`, WordPress still refuses the
|
|
121
|
+
// whole batch (412) if something changed the file in between, instead of silently
|
|
122
|
+
// overwriting it (#234); the vulnerable window is this run's own duration rather than a
|
|
123
|
+
// single request's, wider than option's but still far smaller than not checking at all.
|
|
124
|
+
const expectedRevision = expectedRevisionFor(beforeState, file.filename);
|
|
125
|
+
if (expectedRevision !== undefined)
|
|
126
|
+
entry.expectedRevision = expectedRevision;
|
|
127
|
+
return entry;
|
|
128
|
+
}),
|
|
129
|
+
prune: pruneList,
|
|
130
|
+
};
|
|
131
|
+
let result;
|
|
132
|
+
try {
|
|
133
|
+
result = await this.wp.post(`${spec.endpoint}/batch`, body);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
this.error(`Push failed, nothing was changed on ${this.siteConfig.url}: ${error.message}`);
|
|
137
|
+
}
|
|
138
|
+
for (const file of result.files) {
|
|
139
|
+
this.log(file.syntax_check === 'skipped'
|
|
140
|
+
? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
|
|
141
|
+
: `Pushed: ${file.filename}`);
|
|
142
|
+
if (file.public) {
|
|
143
|
+
this.warn(`${file.filename}: PUBLIC route, no authentication required. Anyone on the internet can call it. See https://loopress.dev/api/routes/#authentication-and-permissions`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const filename of result.pruned)
|
|
147
|
+
this.log(`Pruned: ${filename}`);
|
|
148
|
+
return { pruned: result.pruned, pushed: result.files.map((file) => file.filename) };
|
|
149
|
+
}
|
|
87
150
|
// Mirror of `pull`'s local orphan cleanup, aimed at the server: any file on WordPress with
|
|
88
|
-
// no local counterpart is
|
|
89
|
-
// site. In a non-TTY without `--yes` it refuses rather than prunes: a stray
|
|
90
|
-
// CI must not silently wipe production routes, and unlike `pull`'s local
|
|
91
|
-
// are not recoverable from the repo.
|
|
92
|
-
|
|
151
|
+
// no local counterpart is a candidate for removal. Opt-in (`--prune`) because it removes
|
|
152
|
+
// code from a live site. In a non-TTY without `--yes` it refuses rather than prunes: a stray
|
|
153
|
+
// local path in CI must not silently wipe production routes, and unlike `pull`'s local
|
|
154
|
+
// deletions these are not recoverable from the repo. Only decides *which* filenames to
|
|
155
|
+
// prune; pushBatch() is what actually removes them, atomically alongside the push.
|
|
156
|
+
async resolvePruneList(localFilenames) {
|
|
93
157
|
const remote = await this.wp.get(spec.endpoint);
|
|
94
158
|
const orphans = remote.map((file) => file.filename).filter((name) => !localFilenames.has(name));
|
|
95
159
|
if (orphans.length === 0)
|
|
@@ -110,17 +174,9 @@ export function resourcePushCommand(spec) {
|
|
|
110
174
|
return [];
|
|
111
175
|
}
|
|
112
176
|
}
|
|
113
|
-
|
|
114
|
-
// writes serial.
|
|
115
|
-
const deleted = [];
|
|
116
|
-
for (const filename of orphans) {
|
|
117
|
-
await this.wp.delete(`${spec.endpoint}?filename=${encodeURIComponent(filename)}`);
|
|
118
|
-
this.log(`Pruned: ${filename}`);
|
|
119
|
-
deleted.push(filename);
|
|
120
|
-
}
|
|
121
|
-
return deleted;
|
|
177
|
+
return orphans;
|
|
122
178
|
}
|
|
123
|
-
|
|
179
|
+
validateFileLocally(file, task) {
|
|
124
180
|
if (!spec.filenamePattern.test(file.filename)) {
|
|
125
181
|
const message = `Invalid filename "${file.filename}": ${spec.invalidFilenameHint}`;
|
|
126
182
|
this.reportTaskFailure(message, new Error(message), task);
|
|
@@ -131,27 +187,8 @@ export function resourcePushCommand(spec) {
|
|
|
131
187
|
const message = `${file.filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`;
|
|
132
188
|
this.reportTaskFailure(message, new Error(message), task);
|
|
133
189
|
}
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
task.output = `[dry-run] Would push: ${file.filename}`;
|
|
137
|
-
return undefined;
|
|
138
|
-
}
|
|
139
|
-
try {
|
|
140
|
-
const result = await this.wp.put(spec.endpoint, {
|
|
141
|
-
content: file.content,
|
|
142
|
-
filename: file.filename,
|
|
143
|
-
});
|
|
144
|
-
if (task) {
|
|
145
|
-
task.output =
|
|
146
|
-
result.syntax_check === 'skipped'
|
|
147
|
-
? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
|
|
148
|
-
: `Pushed: ${file.filename}`;
|
|
149
|
-
}
|
|
150
|
-
return result;
|
|
151
|
-
}
|
|
152
|
-
catch (error) {
|
|
153
|
-
this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
|
|
154
|
-
}
|
|
190
|
+
if (task)
|
|
191
|
+
task.output = `Validated: ${file.filename}`;
|
|
155
192
|
}
|
|
156
193
|
}
|
|
157
194
|
return ResourcePush;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { LoopressCommand } from './base.js';
|
|
2
|
+
import { type ResourceState } from './diff-state.js';
|
|
3
|
+
import { type ResourceStateProvider } from './resource-state.js';
|
|
2
4
|
export declare abstract class PushCommand extends LoopressCommand {
|
|
3
5
|
protected failedCount: number;
|
|
4
6
|
private refusedByGuard;
|
|
7
|
+
protected captureBeforePushState(provider: ResourceStateProvider, dir: string): Promise<ResourceState | undefined>;
|
|
5
8
|
catch(err: Error): Promise<void>;
|
|
6
9
|
protected guardProductionPush(): Promise<void>;
|
|
7
10
|
init(): Promise<void>;
|
|
@@ -13,4 +16,5 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
13
16
|
protected runPushTasks<T>(items: T[], label: (item: T) => string, task: (item: T, task?: {
|
|
14
17
|
output: string;
|
|
15
18
|
}) => Promise<void>): Promise<void>;
|
|
19
|
+
protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
|
|
16
20
|
}
|
package/dist/lib/push-command.js
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
import got from 'got';
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
3
4
|
import { authManager } from '../config/auth.manager.js';
|
|
4
5
|
import { API_URL } from './api-client.js';
|
|
5
6
|
import { LoopressCommand } from './base.js';
|
|
6
7
|
import { guardProductionPush } from './guard-production-push.js';
|
|
8
|
+
import { writeSnapshot } from './snapshot-store.js';
|
|
7
9
|
export class PushCommand extends LoopressCommand {
|
|
8
10
|
failedCount = 0;
|
|
9
11
|
// Refusals of the production guard never reach the site, so they must not pollute the
|
|
10
12
|
// deployment history with failure records.
|
|
11
13
|
refusedByGuard = false;
|
|
14
|
+
// Reads `provider`'s remote state right before a real push, for `writeAfterPushSnapshot` to
|
|
15
|
+
// pair with the actual post-push remote state once the push completes. A dry run changes
|
|
16
|
+
// nothing, so there is nothing to snapshot. Best-effort: a read failure (the site briefly
|
|
17
|
+
// unreachable) must never block the push itself, the same "never interrupt the push flow"
|
|
18
|
+
// stance recordDeployment takes; `undefined` tells `writeAfterPushSnapshot` there is no
|
|
19
|
+
// restore point to complete.
|
|
20
|
+
async captureBeforePushState(provider, dir) {
|
|
21
|
+
if (this.dryRun)
|
|
22
|
+
return undefined;
|
|
23
|
+
try {
|
|
24
|
+
return await provider.remote(this.wp, (message) => {
|
|
25
|
+
this.warn(message);
|
|
26
|
+
}, dir);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
this.warn(`Could not read remote state for a rollback snapshot: ${error.message}`);
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
12
33
|
async catch(err) {
|
|
13
34
|
if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
|
|
14
35
|
await this.recordDeployment('failure');
|
|
@@ -73,4 +94,32 @@ export class PushCommand extends LoopressCommand {
|
|
|
73
94
|
title: `Push ${label(item)}`,
|
|
74
95
|
})), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
|
|
75
96
|
}
|
|
97
|
+
// Pairs `beforeState` (from captureBeforePushState, read before this push touched anything)
|
|
98
|
+
// with a fresh remote read taken right after the push, and writes both as one rollback
|
|
99
|
+
// snapshot. Reading the after-state from WordPress itself, rather than assuming it matches
|
|
100
|
+
// the local files that were pushed, is what keeps this correct for every resource's own
|
|
101
|
+
// partial-push semantics: a readonly option skipped by `option push`, a snippet or SEO
|
|
102
|
+
// redirect that gets a server-assigned id on create, a theme-styles push that only touches the
|
|
103
|
+
// active stylesheet, an API/hook push that leaves server-side files alone unless `--prune`.
|
|
104
|
+
// `lps <resource> rollback`'s drift check compares the environment's state at rollback time
|
|
105
|
+
// against exactly this after-state, so it has to be what actually landed, not what was sent.
|
|
106
|
+
async writeAfterPushSnapshot(provider, dir, beforeState) {
|
|
107
|
+
if (!beforeState || this.dryRun)
|
|
108
|
+
return;
|
|
109
|
+
try {
|
|
110
|
+
const afterState = await provider.remote(this.wp, (message) => {
|
|
111
|
+
this.warn(message);
|
|
112
|
+
}, dir);
|
|
113
|
+
await writeSnapshot({
|
|
114
|
+
afterState,
|
|
115
|
+
beforeState,
|
|
116
|
+
environment: this.siteConfig.name,
|
|
117
|
+
resource: provider.resource,
|
|
118
|
+
rootDir: resolve(process.cwd(), this.rootDir),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
this.warn(`Could not save a rollback snapshot after this push: ${error.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
76
125
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
2
3
|
export async function readdirTolerant(dir, options) {
|
|
3
4
|
try {
|
|
4
5
|
return options?.withFileTypes
|
|
@@ -11,3 +12,14 @@ export async function readdirTolerant(dir, options) {
|
|
|
11
12
|
throw error;
|
|
12
13
|
}
|
|
13
14
|
}
|
|
15
|
+
// Every file under `dir`, at any depth, as a path relative to `dir`. Always '/'-joined,
|
|
16
|
+
// never the OS separator: callers match these identities against server-sent filenames
|
|
17
|
+
// (which are always '/'-joined) or embed them in an API payload, so a Windows run must
|
|
18
|
+
// produce the exact same strings as a POSIX one. ENOENT on `dir` itself yields no files,
|
|
19
|
+
// same tolerance as readdirTolerant.
|
|
20
|
+
export async function walkFiles(dir) {
|
|
21
|
+
const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
|
|
22
|
+
return entries
|
|
23
|
+
.filter((entry) => entry.isFile())
|
|
24
|
+
.map((entry) => relative(dir, join(entry.parentPath, entry.name)).split(sep).join('/'));
|
|
25
|
+
}
|