@loopress/cli 0.24.1 → 0.26.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 +721 -90
- 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 +2 -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/page/diff.d.ts +2 -0
- package/dist/commands/page/diff.js +5 -0
- package/dist/commands/page/list.d.ts +8 -0
- package/dist/commands/page/list.js +22 -0
- package/dist/commands/page/push.d.ts +20 -0
- package/dist/commands/page/push.js +60 -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 +10 -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/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +2 -1
- 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 +116 -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 +7 -1
- package/dist/types/project-config.generated.d.ts +12 -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/page-format.d.ts +27 -0
- package/dist/utils/page-format.js +111 -0
- package/dist/utils/resource-dirs.d.ts +3 -0
- package/dist/utils/resource-dirs.js +6 -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 +1633 -168
- package/package.json +4 -2
- package/schemas/project-config.schema.json +15 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { Args, Flags, ux } from '@oclif/core';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { pluralize } from '../utils/pluralize.js';
|
|
7
|
+
import { resolveResourceDir } from '../utils/resource-dirs.js';
|
|
8
|
+
import { LoopressCommand } from './base.js';
|
|
9
|
+
import { compareStates, isEmptyDiff } from './diff-state.js';
|
|
10
|
+
import { guardProductionPush } from './guard-production-push.js';
|
|
11
|
+
import { isInteractive } from './interactive.js';
|
|
12
|
+
import { getResourceStateProvider } from './resource-state.js';
|
|
13
|
+
import { materializeSnapshot } from './rollback-materialize.js';
|
|
14
|
+
import { listSnapshots, readSnapshot, recordToState } from './snapshot-store.js';
|
|
15
|
+
const c = ux.colorize;
|
|
16
|
+
function summarize(diff) {
|
|
17
|
+
return { added: diff.added, changed: diff.changed.map((change) => change.id), removed: diff.removed };
|
|
18
|
+
}
|
|
19
|
+
function indentPatch(patch) {
|
|
20
|
+
return patch
|
|
21
|
+
.split('\n')
|
|
22
|
+
.map((line) => (line === '' ? line : ` ${line}`))
|
|
23
|
+
.join('\n')
|
|
24
|
+
.trimEnd();
|
|
25
|
+
}
|
|
26
|
+
// Builds the `lps <resource> rollback` command for one resource-state-backed resource (the 9
|
|
27
|
+
// listed in resource-state.ts's RESOURCE_STATE_PROVIDERS). `lps <resource> push` writes a
|
|
28
|
+
// snapshot of the environment's state right before every real push (see snapshot-store.ts);
|
|
29
|
+
// this command restores one: by default the most recent, or `--to <id>` for an older one.
|
|
30
|
+
//
|
|
31
|
+
// A rollback is only safe when nothing else touched the environment between the original push
|
|
32
|
+
// and now, otherwise it silently overwrites a legitimate later change. So before restoring
|
|
33
|
+
// anything, this runs the equivalent of `lps <resource> diff` between the environment's current
|
|
34
|
+
// state and the state the original push expected to leave behind, and requires explicit
|
|
35
|
+
// confirmation (the same interactive/`--yes` shape as guardProductionPush) when they differ.
|
|
36
|
+
export function resourceRollbackCommand(resource, options) {
|
|
37
|
+
class ResourceRollback extends LoopressCommand {
|
|
38
|
+
static args = {
|
|
39
|
+
path: Args.string({ description: `Path to ${options.pathNoun} (overrides project config)` }),
|
|
40
|
+
};
|
|
41
|
+
static description = options.description;
|
|
42
|
+
static enableJsonFlag = true;
|
|
43
|
+
static examples = [
|
|
44
|
+
`$ lps ${resource} rollback`,
|
|
45
|
+
`$ lps ${resource} rollback --list`,
|
|
46
|
+
`$ lps ${resource} rollback --to 1732000000000`,
|
|
47
|
+
`$ lps ${resource} rollback --dry-run`,
|
|
48
|
+
];
|
|
49
|
+
static flags = {
|
|
50
|
+
...LoopressCommand.dryRunFlag,
|
|
51
|
+
...LoopressCommand.yesFlag,
|
|
52
|
+
list: Flags.boolean({ description: 'List available snapshots instead of rolling back' }),
|
|
53
|
+
to: Flags.string({ description: 'Roll back to this snapshot id instead of the most recent one' }),
|
|
54
|
+
};
|
|
55
|
+
async run() {
|
|
56
|
+
const { args, flags } = await this.parse(ResourceRollback);
|
|
57
|
+
const provider = getResourceStateProvider(resource);
|
|
58
|
+
const rootDir = resolve(process.cwd(), this.rootDir);
|
|
59
|
+
if (flags.list) {
|
|
60
|
+
const snapshots = await listSnapshots(rootDir, resource, this.siteConfig.name);
|
|
61
|
+
this.renderList(snapshots);
|
|
62
|
+
return { snapshots, status: 'listed' };
|
|
63
|
+
}
|
|
64
|
+
const dir = resolveResourceDir(provider.dirKind, this.localConfig, args.path);
|
|
65
|
+
let snapshot;
|
|
66
|
+
try {
|
|
67
|
+
snapshot = await readSnapshot(rootDir, resource, this.siteConfig.name, flags.to);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
this.error(error.message);
|
|
71
|
+
}
|
|
72
|
+
await guardProductionPush({
|
|
73
|
+
dryRun: this.dryRun,
|
|
74
|
+
error: (message) => this.error(message),
|
|
75
|
+
siteConfig: this.siteConfig,
|
|
76
|
+
yes: this.yes,
|
|
77
|
+
});
|
|
78
|
+
this.log(`Rolling back ${provider.title} on ${this.siteConfig.url} to the snapshot from ${snapshot.createdAt} (${snapshot.id})`);
|
|
79
|
+
const warn = (message) => {
|
|
80
|
+
this.warn(message);
|
|
81
|
+
};
|
|
82
|
+
const currentState = await provider.remote(this.wp, warn, dir);
|
|
83
|
+
// Has anything else changed the environment since the original push? Compare its current
|
|
84
|
+
// state against what that push expected to leave behind (`afterState`), not against the
|
|
85
|
+
// snapshot we're about to restore (`beforeState`), those are expected to differ, that's
|
|
86
|
+
// the whole point of rolling back.
|
|
87
|
+
const drift = compareStates(recordToState(snapshot.afterState), currentState, {
|
|
88
|
+
left: `expected (right after the ${snapshot.createdAt} push)`,
|
|
89
|
+
right: 'current',
|
|
90
|
+
});
|
|
91
|
+
const driftSummary = isEmptyDiff(drift) ? undefined : summarize(drift);
|
|
92
|
+
if (driftSummary) {
|
|
93
|
+
this.renderDiff(`${provider.title} has changed on ${this.siteConfig.url} since that push:`, drift);
|
|
94
|
+
await this.confirmDespiteDrift();
|
|
95
|
+
}
|
|
96
|
+
const restoreState = recordToState(snapshot.beforeState);
|
|
97
|
+
const restoreDiff = compareStates(currentState, restoreState, { left: 'current', right: `snapshot ${snapshot.id}` });
|
|
98
|
+
if (isEmptyDiff(restoreDiff)) {
|
|
99
|
+
this.log('Nothing to restore, the environment already matches this snapshot.');
|
|
100
|
+
return { drift: driftSummary, id: snapshot.id, restored: summarize(restoreDiff), status: 'no-op' };
|
|
101
|
+
}
|
|
102
|
+
this.renderDiff(`This would restore ${provider.title} to:`, restoreDiff);
|
|
103
|
+
// `<resource> push` only creates and updates, it never deletes (see each resource's own
|
|
104
|
+
// push command, e.g. option push's "upsert only, never deletes an untracked option"):
|
|
105
|
+
// an item present now but absent from the snapshot stays present after "restoring" it.
|
|
106
|
+
if (restoreDiff.removed.length > 0) {
|
|
107
|
+
this.warn(`${pluralize(restoreDiff.removed.length, 'item')} present now but not in this snapshot will NOT be removed ` +
|
|
108
|
+
`(${restoreDiff.removed.join(', ')}): restoring only creates and updates, the same as \`lps ${resource} push\`. Remove ${restoreDiff.removed.length === 1 ? 'it' : 'them'} by hand if that's part of undoing the original push.`);
|
|
109
|
+
}
|
|
110
|
+
if (this.dryRun) {
|
|
111
|
+
return { drift: driftSummary, id: snapshot.id, restored: summarize(restoreDiff), status: 'dry-run' };
|
|
112
|
+
}
|
|
113
|
+
// Only the items that actually need restoring, not the whole snapshot: an item already
|
|
114
|
+
// matching `beforeState` needs no push, and re-pushing it anyway would put every other
|
|
115
|
+
// item this resource ever had at push time (however unrelated to what actually drifted)
|
|
116
|
+
// through the delegated push's own validation, where a single unrelated failure would
|
|
117
|
+
// fail the whole rollback.
|
|
118
|
+
const idsToRestore = new Set([...restoreDiff.added, ...restoreDiff.changed.map((change) => change.id)]);
|
|
119
|
+
const stateToRestore = Object.fromEntries(Object.entries(snapshot.beforeState).filter(([id]) => idsToRestore.has(id)));
|
|
120
|
+
const tmpDir = await mkdtemp(join(tmpdir(), `loopress-rollback-${resource}-`));
|
|
121
|
+
try {
|
|
122
|
+
await materializeSnapshot(resource, stateToRestore, tmpDir);
|
|
123
|
+
// tmpDir is positional (the same [PATH] arg every resource push command takes, see e.g.
|
|
124
|
+
// commands/snippet/push.ts's `static args`), not a --path flag. --env/--yes: the
|
|
125
|
+
// production guard and the drift confirmation above already covered what the delegated
|
|
126
|
+
// push's own guard would ask again, same reasoning as the top-level `lps push`
|
|
127
|
+
// delegating to each resource's push command in commands/push.ts.
|
|
128
|
+
await this.config.runCommand(`${resource}:push`, [tmpDir, '--env', this.siteConfig.name, '--yes']);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
// A cleanup failure here must never hide a real error from the push above: warn about
|
|
132
|
+
// it and let the original error (if any) keep propagating instead of being replaced.
|
|
133
|
+
await rm(tmpDir, { force: true, recursive: true }).catch((error) => {
|
|
134
|
+
this.warn(`Could not remove the temporary directory "${tmpDir}": ${error.message}`);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
this.log(`Rolled back ${provider.title} to the snapshot from ${snapshot.createdAt}.`);
|
|
138
|
+
return { drift: driftSummary, id: snapshot.id, restored: summarize(restoreDiff), status: 'success' };
|
|
139
|
+
}
|
|
140
|
+
async confirmDespiteDrift() {
|
|
141
|
+
if (this.dryRun || this.yes)
|
|
142
|
+
return;
|
|
143
|
+
if (!isInteractive()) {
|
|
144
|
+
this.error('The environment has changed since this snapshot was taken. Re-run with --yes to roll back anyway (overwriting those later changes).');
|
|
145
|
+
}
|
|
146
|
+
const proceed = await confirm({
|
|
147
|
+
default: false,
|
|
148
|
+
message: 'The environment has changed since this snapshot was taken. Roll back anyway, overwriting those later changes?',
|
|
149
|
+
});
|
|
150
|
+
if (!proceed)
|
|
151
|
+
this.error('Aborted.');
|
|
152
|
+
}
|
|
153
|
+
renderDiff(heading, diff) {
|
|
154
|
+
if (this.jsonEnabled())
|
|
155
|
+
return;
|
|
156
|
+
this.log(c('bold', heading));
|
|
157
|
+
for (const id of diff.added)
|
|
158
|
+
this.log(c('green', ` + ${id}`));
|
|
159
|
+
for (const id of diff.removed)
|
|
160
|
+
this.log(c('red', ` - ${id}`));
|
|
161
|
+
for (const change of diff.changed) {
|
|
162
|
+
this.log(c('yellow', ` ~ ${change.id}`));
|
|
163
|
+
this.log(indentPatch(change.patch));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
renderList(snapshots) {
|
|
167
|
+
if (this.jsonEnabled())
|
|
168
|
+
return;
|
|
169
|
+
if (snapshots.length === 0) {
|
|
170
|
+
this.log(`No snapshots found for "${resource}" on "${this.siteConfig.name}". A snapshot is written automatically by \`lps ${resource} push\`.`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.log(`Snapshots for "${resource}" on "${this.siteConfig.name}" (most recent first):`);
|
|
174
|
+
for (const snapshot of snapshots) {
|
|
175
|
+
this.log(` ${snapshot.id} ${snapshot.createdAt}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return ResourceRollback;
|
|
180
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function materializeSnapshot(resource: string, state: Record<string, unknown>, dir: string): Promise<void>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { getAcfKey } from '../utils/acf-format.js';
|
|
4
|
+
import { getFormId, getFormTitle } from '../utils/form-format.js';
|
|
5
|
+
import { getMenuSlug } from '../utils/menu-format.js';
|
|
6
|
+
import { defaultReadonlyFor, optionFileName } from '../utils/option-format.js';
|
|
7
|
+
import { redirectFileBase } from '../utils/seo-format.js';
|
|
8
|
+
import { buildMetaFile, buildSnippetFile } from '../utils/snippet-format.js';
|
|
9
|
+
import { themeStylesFileName } from '../utils/theme-styles-format.js';
|
|
10
|
+
import { toSlug } from '../utils/to-slug.js';
|
|
11
|
+
const SNIPPET_EXTENSIONS = { css: 'css', html: 'html', js: 'js', php: 'php', text: 'txt' };
|
|
12
|
+
async function writeJson(path, value) {
|
|
13
|
+
await mkdir(dirname(path), { recursive: true });
|
|
14
|
+
await writeFile(path, JSON.stringify(value, null, 2) + '\n');
|
|
15
|
+
}
|
|
16
|
+
// id: the snapshot's map key, a numeric WordPress id (snapshots only ever come from
|
|
17
|
+
// `provider.remote()`, which never produces the "local:<file>" placeholder ids a not-yet-pushed
|
|
18
|
+
// local file gets, see resource-state.ts). value: the canonical snippet fields resource-state.ts
|
|
19
|
+
// compares (no `id`, no `description`, `code` with its `<?php` tag already stripped), exactly
|
|
20
|
+
// what `normalizeSnippet` + `stripPhpOpeningTag` produce, so it plugs straight into the same
|
|
21
|
+
// `buildSnippetFile`/`buildMetaFile` pair `snippet pull` uses to write files.
|
|
22
|
+
async function snippet(id, value, dir) {
|
|
23
|
+
const fields = value;
|
|
24
|
+
const normalized = { ...fields, description: '', id: Number(id) };
|
|
25
|
+
const ext = SNIPPET_EXTENSIONS[normalized.type];
|
|
26
|
+
const base = `${normalized.id}-${toSlug(normalized.name)}`;
|
|
27
|
+
await mkdir(dir, { recursive: true });
|
|
28
|
+
await writeFile(join(dir, `${base}.${ext}`), buildSnippetFile(normalized));
|
|
29
|
+
await writeFile(join(dir, `${base}.json`), buildMetaFile(normalized));
|
|
30
|
+
}
|
|
31
|
+
// value is the whole form JSON (minus `modified`/`modified_gmt`), the same shape `form pull`
|
|
32
|
+
// writes verbatim; `form push` re-sends it as-is.
|
|
33
|
+
async function form(id, value, dir) {
|
|
34
|
+
const data = value;
|
|
35
|
+
const formId = getFormId(data) ?? Number(id);
|
|
36
|
+
const slug = toSlug(getFormTitle(data), 'untitled');
|
|
37
|
+
await writeJson(join(dir, `${formId}-${slug}.json`), data);
|
|
38
|
+
}
|
|
39
|
+
// id is `<type>/<key>` (see resource-state.ts's acfProvider); value is the whole ACF export
|
|
40
|
+
// object (minus `modified`), already carrying its own `key`.
|
|
41
|
+
async function acf(id, value, dir) {
|
|
42
|
+
const separator = id.indexOf('/');
|
|
43
|
+
const type = id.slice(0, separator);
|
|
44
|
+
const data = value;
|
|
45
|
+
const key = getAcfKey(data) ?? id.slice(separator + 1);
|
|
46
|
+
await writeJson(join(dir, type, `${key}.json`), data);
|
|
47
|
+
}
|
|
48
|
+
// api/hook: id is the filename (possibly nested, `/`-joined); value is the raw PHP file text.
|
|
49
|
+
async function phpFile(id, value, dir) {
|
|
50
|
+
const filePath = join(dir, `${id}.php`);
|
|
51
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
52
|
+
await writeFile(filePath, String(value));
|
|
53
|
+
}
|
|
54
|
+
// ids are 'settings', `post-meta/<postType>/<slug>`, or `redirects/<id>` (see resource-state.ts's
|
|
55
|
+
// seoProvider). Each value round-trips to the exact file `seo pull` would have written.
|
|
56
|
+
async function seo(id, value, dir) {
|
|
57
|
+
if (id === 'settings') {
|
|
58
|
+
await writeJson(join(dir, 'settings.json'), value);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (id.startsWith('post-meta/')) {
|
|
62
|
+
const rest = id.slice('post-meta/'.length);
|
|
63
|
+
const separator = rest.indexOf('/');
|
|
64
|
+
const postType = rest.slice(0, separator);
|
|
65
|
+
const slug = rest.slice(separator + 1);
|
|
66
|
+
await writeJson(join(dir, 'post-meta', postType, `${slug}.json`), value);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (id.startsWith('redirects/')) {
|
|
70
|
+
const redirect = value;
|
|
71
|
+
await writeJson(join(dir, 'redirects', `${redirectFileBase(redirect)}.json`), redirect);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`Unrecognized SEO snapshot id "${id}"`);
|
|
75
|
+
}
|
|
76
|
+
// ids are 'menu-locations' or `menu/<slug>` (see resource-state.ts's menuProvider).
|
|
77
|
+
async function menu(id, value, dir) {
|
|
78
|
+
if (id === 'menu-locations') {
|
|
79
|
+
await writeJson(join(dir, 'menu-locations.json'), value);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const data = value;
|
|
83
|
+
const slug = getMenuSlug(data) ?? id.slice('menu/'.length);
|
|
84
|
+
await writeJson(join(dir, `${slug}.json`), data);
|
|
85
|
+
}
|
|
86
|
+
// `value` is `{autoload, value}` (resource-state.ts's optionsProvider deliberately drops
|
|
87
|
+
// `readonly`: it's a local policy flag with no WordPress counterpart). Restoring it as writable
|
|
88
|
+
// by default would silently lift that protection for a rollback that happens to include a
|
|
89
|
+
// dangerous option (siteurl, home, ...); defaulting to the same policy `option add` itself uses
|
|
90
|
+
// keeps a rolled-back option exactly as protected as a freshly tracked one, never less.
|
|
91
|
+
async function option(id, value, dir) {
|
|
92
|
+
const { autoload, value: optionValue } = value;
|
|
93
|
+
const local = { autoload, name: id, readonly: defaultReadonlyFor(id), value: optionValue };
|
|
94
|
+
await writeJson(join(dir, optionFileName(id)), local);
|
|
95
|
+
}
|
|
96
|
+
// id is the stylesheet slug; value is `{settings, styles}` (canonicalGlobalStyles' shape).
|
|
97
|
+
async function themeStyles(id, value, dir) {
|
|
98
|
+
await writeJson(join(dir, themeStylesFileName(id)), value);
|
|
99
|
+
}
|
|
100
|
+
const MATERIALIZERS = {
|
|
101
|
+
acf,
|
|
102
|
+
api: phpFile,
|
|
103
|
+
form,
|
|
104
|
+
hook: phpFile,
|
|
105
|
+
menu,
|
|
106
|
+
option,
|
|
107
|
+
seo,
|
|
108
|
+
snippet,
|
|
109
|
+
'theme-styles': themeStyles,
|
|
110
|
+
};
|
|
111
|
+
// Writes a snapshot's state back to `dir` in the exact on-disk layout `<resource> pull` would
|
|
112
|
+
// have produced, so the existing `<resource> push` command can push it unmodified: a rollback
|
|
113
|
+
// is push() applied to an archived state instead of the current local files.
|
|
114
|
+
export async function materializeSnapshot(resource, state, dir) {
|
|
115
|
+
const materialize = MATERIALIZERS[resource];
|
|
116
|
+
if (!materialize)
|
|
117
|
+
throw new Error(`Rollback is not supported for "${resource}".`);
|
|
118
|
+
await mkdir(dir, { recursive: true });
|
|
119
|
+
for (const [id, value] of Object.entries(state))
|
|
120
|
+
await materialize(id, value, dir);
|
|
121
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ResourceState } from './diff-state.js';
|
|
2
|
+
export declare const SNAPSHOT_RETENTION = 10;
|
|
3
|
+
export type Snapshot = {
|
|
4
|
+
afterState: Record<string, unknown>;
|
|
5
|
+
beforeState: Record<string, unknown>;
|
|
6
|
+
createdAt: string;
|
|
7
|
+
environment: string;
|
|
8
|
+
resource: string;
|
|
9
|
+
};
|
|
10
|
+
export type SnapshotSummary = {
|
|
11
|
+
createdAt: string;
|
|
12
|
+
environment: string;
|
|
13
|
+
id: string;
|
|
14
|
+
resource: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function stateToRecord(state: ResourceState): Record<string, unknown>;
|
|
17
|
+
export declare function recordToState(record: Record<string, unknown>): ResourceState;
|
|
18
|
+
export declare function writeSnapshot(options: {
|
|
19
|
+
afterState: ResourceState;
|
|
20
|
+
beforeState: ResourceState;
|
|
21
|
+
environment: string;
|
|
22
|
+
resource: string;
|
|
23
|
+
retain?: number;
|
|
24
|
+
rootDir: string;
|
|
25
|
+
}): Promise<string>;
|
|
26
|
+
export declare function listSnapshots(rootDir: string, resource: string, environment: string): Promise<SnapshotSummary[]>;
|
|
27
|
+
export declare function readSnapshot(rootDir: string, resource: string, environment: string, id?: string): Promise<Snapshot & {
|
|
28
|
+
id: string;
|
|
29
|
+
}>;
|
|
30
|
+
export declare function pruneSnapshots(rootDir: string, resource: string, environment: string, retain: number): Promise<void>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
// How many snapshots `writeSnapshot` keeps per resource before pruning the oldest, so the
|
|
4
|
+
// cache directory doesn't grow without bound across a project's lifetime.
|
|
5
|
+
export const SNAPSHOT_RETENTION = 10;
|
|
6
|
+
// `ResourceState` (a Map, for O(1) lookups during comparison) has no direct JSON
|
|
7
|
+
// representation; snapshots round-trip through a plain object instead. Keys are sorted so two
|
|
8
|
+
// snapshots of the same state serialize identically (stable diffs, deterministic tests).
|
|
9
|
+
export function stateToRecord(state) {
|
|
10
|
+
return Object.fromEntries([...state].sort(([a], [b]) => a.localeCompare(b)));
|
|
11
|
+
}
|
|
12
|
+
export function recordToState(record) {
|
|
13
|
+
return new Map(Object.entries(record));
|
|
14
|
+
}
|
|
15
|
+
// Environment names are free text (whatever `lps project config` was given), not guaranteed to
|
|
16
|
+
// be filesystem-safe path segments; anything outside a conservative safe set becomes '-'.
|
|
17
|
+
function sanitizeForPath(name) {
|
|
18
|
+
return name.replaceAll(/[^\w-]/g, '-');
|
|
19
|
+
}
|
|
20
|
+
// Scoped by environment, not just resource: a project with multiple environments (staging,
|
|
21
|
+
// production, ...) sharing one rootDir must never let a staging push's snapshot answer a
|
|
22
|
+
// production rollback, or a staging push's retention prune a production restore point.
|
|
23
|
+
function snapshotsDir(rootDir, resource, environment) {
|
|
24
|
+
return join(rootDir, '.loopress', 'snapshots', resource, sanitizeForPath(environment));
|
|
25
|
+
}
|
|
26
|
+
function snapshotPath(rootDir, resource, environment, id) {
|
|
27
|
+
return join(snapshotsDir(rootDir, resource, environment), `${id}.json`);
|
|
28
|
+
}
|
|
29
|
+
function isEnoent(error) {
|
|
30
|
+
return error.code === 'ENOENT';
|
|
31
|
+
}
|
|
32
|
+
function isEexist(error) {
|
|
33
|
+
return error.code === 'EEXIST';
|
|
34
|
+
}
|
|
35
|
+
// Snapshot ids are the millisecond timestamp they were written at: sortable, unique enough for
|
|
36
|
+
// a single-machine CLI run, and directly usable as `--to <id>`.
|
|
37
|
+
function isSnapshotFilename(name) {
|
|
38
|
+
return /^\d+\.json$/.test(name);
|
|
39
|
+
}
|
|
40
|
+
// Writes a snapshot for `resource`/`environment`, then prunes down to `retain` (oldest first),
|
|
41
|
+
// so a project that pushes often never accumulates an unbounded `.loopress/snapshots/`
|
|
42
|
+
// directory. Returns the new snapshot's id.
|
|
43
|
+
//
|
|
44
|
+
// Ids are millisecond timestamps; two pushes of the same resource/environment landing in the
|
|
45
|
+
// same millisecond (only realistic from concurrent processes, never a single sequential CLI
|
|
46
|
+
// run) would otherwise silently overwrite one restore point with another. `wx` (exclusive
|
|
47
|
+
// create) turns that into a detected collision instead, retried against the next millisecond.
|
|
48
|
+
export async function writeSnapshot(options) {
|
|
49
|
+
const { afterState, beforeState, environment, resource, retain = SNAPSHOT_RETENTION, rootDir } = options;
|
|
50
|
+
const dir = snapshotsDir(rootDir, resource, environment);
|
|
51
|
+
await mkdir(dir, { recursive: true });
|
|
52
|
+
const snapshot = {
|
|
53
|
+
afterState: stateToRecord(afterState),
|
|
54
|
+
beforeState: stateToRecord(beforeState),
|
|
55
|
+
createdAt: new Date().toISOString(),
|
|
56
|
+
environment,
|
|
57
|
+
resource,
|
|
58
|
+
};
|
|
59
|
+
const content = JSON.stringify(snapshot, null, 2) + '\n';
|
|
60
|
+
let id = Date.now();
|
|
61
|
+
for (;;) {
|
|
62
|
+
try {
|
|
63
|
+
await writeFile(join(dir, `${id}.json`), content, { flag: 'wx' });
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (!isEexist(error))
|
|
68
|
+
throw error;
|
|
69
|
+
id += 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await pruneSnapshots(rootDir, resource, environment, retain);
|
|
73
|
+
return String(id);
|
|
74
|
+
}
|
|
75
|
+
// Newest first. A missing snapshots directory (never pushed, or pruned to nothing) reads as
|
|
76
|
+
// "no snapshots", not an error, the same ENOENT tolerance every resource-state provider gives
|
|
77
|
+
// a never-pulled local directory.
|
|
78
|
+
export async function listSnapshots(rootDir, resource, environment) {
|
|
79
|
+
const dir = snapshotsDir(rootDir, resource, environment);
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = await readdir(dir);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (isEnoent(error))
|
|
86
|
+
return [];
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
const summaries = [];
|
|
90
|
+
const snapshotFiles = entries.filter((entry) => isSnapshotFilename(entry));
|
|
91
|
+
for (const entry of snapshotFiles) {
|
|
92
|
+
const id = entry.slice(0, -'.json'.length);
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(await readFile(join(dir, entry), 'utf8'));
|
|
95
|
+
summaries.push({ createdAt: parsed.createdAt, environment: parsed.environment, id, resource });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// A corrupted or partially-written snapshot file (e.g. an interrupted write) is skipped
|
|
99
|
+
// rather than failing the whole listing; it stays on disk for a human to inspect.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return summaries.sort((a, b) => Number(b.id) - Number(a.id));
|
|
103
|
+
}
|
|
104
|
+
// `id` undefined reads the most recent snapshot for this environment (the common
|
|
105
|
+
// `lps <resource> rollback` case). Throws a message ready to show the user directly: no
|
|
106
|
+
// snapshots at all for this environment, or that specific id missing (renamed, deleted, or
|
|
107
|
+
// written under a different resource/environment).
|
|
108
|
+
export async function readSnapshot(rootDir, resource, environment, id) {
|
|
109
|
+
let targetId = id;
|
|
110
|
+
if (targetId === undefined) {
|
|
111
|
+
const [latest] = await listSnapshots(rootDir, resource, environment);
|
|
112
|
+
if (!latest) {
|
|
113
|
+
throw new Error(`No snapshots found for "${resource}" on "${environment}". A snapshot is written automatically by \`lps ${resource} push\`.`);
|
|
114
|
+
}
|
|
115
|
+
targetId = latest.id;
|
|
116
|
+
}
|
|
117
|
+
let raw;
|
|
118
|
+
try {
|
|
119
|
+
raw = await readFile(snapshotPath(rootDir, resource, environment, targetId), 'utf8');
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (isEnoent(error)) {
|
|
123
|
+
throw new Error(`Snapshot "${targetId}" not found for "${resource}" on "${environment}". Run \`lps ${resource} rollback --list\` to see what's available.`, { cause: error });
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
return { ...JSON.parse(raw), id: targetId };
|
|
128
|
+
}
|
|
129
|
+
export async function pruneSnapshots(rootDir, resource, environment, retain) {
|
|
130
|
+
const summaries = await listSnapshots(rootDir, resource, environment);
|
|
131
|
+
const stale = summaries.slice(retain);
|
|
132
|
+
await Promise.all(stale.map(async (summary) => rm(snapshotPath(rootDir, resource, environment, summary.id), { force: true })));
|
|
133
|
+
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { configManager } from '../config/project-config.manager.js';
|
|
4
|
+
import { readLocalPages } from '../utils/page-format.js';
|
|
4
5
|
import { parseType } from '../utils/snippet-format.js';
|
|
5
6
|
import { loadSnippets } from './load-snippets.js';
|
|
6
7
|
import { readdirTolerant } from './readdir-tolerant.js';
|
|
7
8
|
// Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
|
|
8
9
|
// and api routes (`.php`, not JSON) are checked separately.
|
|
9
|
-
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir'];
|
|
10
|
+
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir', 'menuDir'];
|
|
10
11
|
const DEFAULT_DIR = {
|
|
11
12
|
acfDir: 'acf',
|
|
12
13
|
apiDir: 'api',
|
|
13
14
|
formDir: 'forms',
|
|
14
15
|
hooksDir: 'hooks',
|
|
16
|
+
menuDir: 'menus',
|
|
17
|
+
pageDir: 'pages',
|
|
15
18
|
seoDir: 'seo',
|
|
16
19
|
snippetsDir: 'snippets',
|
|
17
20
|
};
|
|
@@ -37,6 +40,9 @@ export async function validateLocal(cwd) {
|
|
|
37
40
|
checked += await checkSnippets(resolve('snippetsDir'), problems);
|
|
38
41
|
checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty');
|
|
39
42
|
checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty');
|
|
43
|
+
const pages = await readLocalPages(resolve('pageDir'));
|
|
44
|
+
checked += pages.pages.length + pages.problems.length;
|
|
45
|
+
problems.push(...pages.problems);
|
|
40
46
|
return { checked, problems, valid: problems.length === 0 };
|
|
41
47
|
}
|
|
42
48
|
// Recursive: both api/ and hooks/ files can live in a nested path-segment directory
|
|
@@ -35,10 +35,22 @@ export interface LoopressProjectConfiguration {
|
|
|
35
35
|
* Directory where single-page app bundles are read from and written to, relative to rootDir. Each subdirectory is one app: a loopress.app.json plus a built dist/ folder.
|
|
36
36
|
*/
|
|
37
37
|
appsDir?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Directory where static HTML pages (one <slug>.html per WordPress page, managed by Loopress) are read from, relative to rootDir.
|
|
40
|
+
*/
|
|
41
|
+
pageDir?: string;
|
|
38
42
|
/**
|
|
39
43
|
* Directory where explicitly tracked WordPress options (wp_options rows) are read from and written to, relative to rootDir. Only options added via `lps option add` live here, this is never the whole options table.
|
|
40
44
|
*/
|
|
41
45
|
optionsDir?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Directory where nav menus (one JSON file per menu, plus menu-locations.json for the active theme's menu locations) are read from and written to, relative to rootDir.
|
|
48
|
+
*/
|
|
49
|
+
menuDir?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Directory where the active block theme's Global Styles customizations (Site Editor > Styles: colors, typography, spacing) are read from and written to, relative to rootDir. Classic (non-block) themes are not supported.
|
|
52
|
+
*/
|
|
53
|
+
themeStylesDir?: string;
|
|
42
54
|
/**
|
|
43
55
|
* Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
|
|
44
56
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export declare const ACF_OBJECT_TYPES: readonly ["field-groups", "post-types", "taxonomies", "options-pages"];
|
|
2
2
|
export type AcfObjectType = (typeof ACF_OBJECT_TYPES)[number];
|
|
3
3
|
export declare function acfEndpoint(type: AcfObjectType): string;
|
|
4
|
+
export declare function acfObjectEndpoint(type: AcfObjectType, key: string): string;
|
|
4
5
|
export declare function getAcfKey(data: Record<string, unknown>): null | string;
|
package/dist/utils/acf-format.js
CHANGED
|
@@ -2,6 +2,12 @@ export const ACF_OBJECT_TYPES = ['field-groups', 'post-types', 'taxonomies', 'op
|
|
|
2
2
|
export function acfEndpoint(type) {
|
|
3
3
|
return `loopress/v1/acf/${type}`;
|
|
4
4
|
}
|
|
5
|
+
// GET/DELETE route for a single object by key, used by `acf push` to read the object's current
|
|
6
|
+
// revision right before writing it (see acf/push.ts, #234). Create/update both go through
|
|
7
|
+
// acfEndpoint() (a POST), the controller resolves create-vs-update server-side via `key`.
|
|
8
|
+
export function acfObjectEndpoint(type, key) {
|
|
9
|
+
return `${acfEndpoint(type)}/${key}`;
|
|
10
|
+
}
|
|
5
11
|
// Deliberately loose: ACF's own export JSON is large, deeply nested, and versioned by ACF
|
|
6
12
|
// itself (new settings appear across ACF releases). We only need `key` for filenames/identity;
|
|
7
13
|
// everything else round-trips through pull/push untouched, so there's no generated type for it
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export declare const FORM_ENDPOINT = "loopress/v1/forms";
|
|
2
|
+
export declare function formEndpoint(id: number): string;
|
|
3
|
+
export type RemoteForm = Record<string, unknown> & {
|
|
4
|
+
revision: string;
|
|
5
|
+
};
|
|
2
6
|
export declare function getFormId(data: Record<string, unknown>): null | number;
|
|
3
7
|
export declare function getFormTitle(data: Record<string, unknown>): string;
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
export const FORM_ENDPOINT = 'loopress/v1/forms';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// Snippets/WPCode split) is large, deeply nested, and versioned by whichever plugin release
|
|
6
|
-
// wrote it. We only need `id` for filenames/identity and `settings.form_title` for display
|
|
7
|
-
// (the field WPForms itself uses; a future provider may differ), everything else round-trips
|
|
8
|
-
// through pull/push untouched.
|
|
2
|
+
export function formEndpoint(id) {
|
|
3
|
+
return `${FORM_ENDPOINT}/${id}`;
|
|
4
|
+
}
|
|
9
5
|
export function getFormId(data) {
|
|
10
6
|
const id = Number(data.id);
|
|
11
7
|
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const MENU_ENDPOINT = "loopress/v1/menus";
|
|
2
|
+
export declare const MENU_LOCATIONS_ENDPOINT = "loopress/v1/menu-locations";
|
|
3
|
+
export declare function menuEndpoint(slug: string): string;
|
|
4
|
+
export type MenuItem = {
|
|
5
|
+
children: MenuItem[];
|
|
6
|
+
classes: string[];
|
|
7
|
+
description: string;
|
|
8
|
+
object: null | string;
|
|
9
|
+
objectSlug: null | string;
|
|
10
|
+
target: string;
|
|
11
|
+
title: string;
|
|
12
|
+
type: 'custom' | 'post_type' | 'taxonomy';
|
|
13
|
+
url: null | string;
|
|
14
|
+
xfn: string;
|
|
15
|
+
};
|
|
16
|
+
export type Menu = {
|
|
17
|
+
items: MenuItem[];
|
|
18
|
+
name: string;
|
|
19
|
+
revision: string;
|
|
20
|
+
slug: string;
|
|
21
|
+
warnings: string[];
|
|
22
|
+
};
|
|
23
|
+
export type MenuLocations = Record<string, null | string>;
|
|
24
|
+
export declare function getMenuSlug(data: Record<string, unknown>): null | string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const MENU_ENDPOINT = 'loopress/v1/menus';
|
|
2
|
+
export const MENU_LOCATIONS_ENDPOINT = 'loopress/v1/menu-locations';
|
|
3
|
+
export function menuEndpoint(slug) {
|
|
4
|
+
return `${MENU_ENDPOINT}/${encodeURIComponent(slug)}`;
|
|
5
|
+
}
|
|
6
|
+
export function getMenuSlug(data) {
|
|
7
|
+
return typeof data.slug === 'string' && data.slug.trim() !== '' ? data.slug : null;
|
|
8
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const PAGES_ENDPOINT = "loopress/v1/pages";
|
|
2
|
+
export declare const PAGE_SLUG_PATTERN: RegExp;
|
|
3
|
+
export declare const PAGE_STATUSES: readonly ["draft", "publish"];
|
|
4
|
+
export type PageStatus = (typeof PAGE_STATUSES)[number];
|
|
5
|
+
export type Page = {
|
|
6
|
+
fullWidth: boolean;
|
|
7
|
+
hideTitle: boolean;
|
|
8
|
+
html: string;
|
|
9
|
+
slug: string;
|
|
10
|
+
status: PageStatus;
|
|
11
|
+
template: string;
|
|
12
|
+
title: string;
|
|
13
|
+
};
|
|
14
|
+
export type RemotePage = Page & {
|
|
15
|
+
link: string;
|
|
16
|
+
};
|
|
17
|
+
export type PageProblem = {
|
|
18
|
+
file: string;
|
|
19
|
+
message: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function formatPageProblems(problems: PageProblem[]): string;
|
|
22
|
+
export declare function titleFromSlug(slug: string): string;
|
|
23
|
+
export declare function parsePageFile(slug: string, raw: string): Page;
|
|
24
|
+
export declare function readLocalPages(dir: string): Promise<{
|
|
25
|
+
pages: Page[];
|
|
26
|
+
problems: PageProblem[];
|
|
27
|
+
}>;
|