@loopress/cli 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +515 -83
- package/dist/commands/acf/diff.d.ts +2 -0
- package/dist/commands/acf/diff.js +5 -0
- package/dist/commands/api/diff.d.ts +2 -0
- package/dist/commands/api/diff.js +5 -0
- package/dist/commands/api/list.d.ts +2 -12
- package/dist/commands/api/list.js +2 -20
- package/dist/commands/api/pull.d.ts +2 -20
- package/dist/commands/api/pull.js +2 -53
- package/dist/commands/api/push.d.ts +2 -22
- package/dist/commands/api/push.js +2 -116
- package/dist/commands/composer/pull.js +6 -16
- package/dist/commands/dev.js +1 -1
- package/dist/commands/diff.d.ts +13 -0
- package/dist/commands/diff.js +42 -0
- package/dist/commands/form/diff.d.ts +2 -0
- package/dist/commands/form/diff.js +5 -0
- package/dist/commands/hook/diff.d.ts +2 -0
- package/dist/commands/hook/diff.js +5 -0
- package/dist/commands/hook/list.d.ts +2 -0
- package/dist/commands/hook/list.js +2 -0
- package/dist/commands/hook/pull.d.ts +2 -0
- package/dist/commands/hook/pull.js +2 -0
- package/dist/commands/hook/push.d.ts +2 -0
- package/dist/commands/hook/push.js +2 -0
- package/dist/commands/init.js +1 -1
- package/dist/commands/option/add.d.ts +15 -0
- package/dist/commands/option/add.js +47 -0
- package/dist/commands/option/diff.d.ts +2 -0
- package/dist/commands/option/diff.js +5 -0
- package/dist/commands/{page → option}/list.d.ts +5 -1
- package/dist/commands/option/list.js +151 -0
- package/dist/commands/{page → option}/pull.d.ts +3 -12
- package/dist/commands/option/pull.js +75 -0
- package/dist/commands/{page → option}/push.d.ts +3 -8
- package/dist/commands/option/push.js +66 -0
- package/dist/commands/option/remove.d.ts +22 -0
- package/dist/commands/option/remove.js +69 -0
- package/dist/commands/plugin/push.d.ts +0 -1
- package/dist/commands/plugin/push.js +3 -10
- package/dist/commands/pull.js +3 -2
- package/dist/commands/push.js +4 -3
- package/dist/commands/seo/diff.d.ts +2 -0
- package/dist/commands/seo/diff.js +5 -0
- package/dist/commands/snippet/diff.d.ts +2 -0
- package/dist/commands/snippet/diff.js +5 -0
- package/dist/commands/theme/push.d.ts +0 -1
- package/dist/commands/theme/push.js +3 -10
- package/dist/lib/base.d.ts +2 -1
- package/dist/lib/base.js +12 -22
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +1 -2
- package/dist/lib/diff-command.d.ts +47 -0
- package/dist/lib/diff-command.js +180 -0
- package/dist/lib/diff-state.d.ts +22 -0
- package/dist/lib/diff-state.js +88 -0
- package/dist/lib/find-orphaned-files.js +1 -1
- package/dist/lib/interactive.d.ts +1 -0
- package/dist/lib/interactive.js +10 -0
- package/dist/lib/php-files-command.d.ts +54 -0
- package/dist/lib/php-files-command.js +216 -0
- package/dist/lib/put-or-create.js +1 -1
- package/dist/lib/readdir-tolerant.d.ts +1 -0
- package/dist/lib/readdir-tolerant.js +3 -1
- package/dist/lib/resource-state.d.ts +14 -0
- package/dist/lib/resource-state.js +361 -0
- package/dist/lib/validate-local.js +15 -7
- package/dist/lib/wp-client.d.ts +5 -0
- package/dist/lib/wp-client.js +56 -0
- package/dist/types/project-config.generated.d.ts +8 -4
- package/dist/utils/option-format.d.ts +31 -0
- package/dist/utils/option-format.js +51 -0
- package/dist/utils/resource-dirs.d.ts +13 -0
- package/dist/utils/resource-dirs.js +34 -0
- package/oclif.manifest.json +1156 -149
- package/package.json +10 -7
- package/schemas/project-config.schema.json +10 -5
- package/dist/commands/page/list.js +0 -19
- package/dist/commands/page/pull.js +0 -57
- package/dist/commands/page/push.js +0 -132
- package/dist/utils/page-format.d.ts +0 -7
- package/dist/utils/page-format.js +0 -78
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Flags, ux } from '@oclif/core';
|
|
2
|
+
import ttyTable from 'tty-table';
|
|
3
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
4
|
+
import { OPTIONS_ENDPOINT } from '../../utils/option-format.js';
|
|
5
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
6
|
+
// Same green/yellow pairing drives both SOURCE? and PLUGIN, so a row's whole "where did this come
|
|
7
|
+
// from" story reads as one color at a glance instead of requiring the "?" to be spotted in text.
|
|
8
|
+
function confidenceColor(option) {
|
|
9
|
+
if (!option.guess)
|
|
10
|
+
return undefined;
|
|
11
|
+
return option.confirmed ? 'green' : 'yellow';
|
|
12
|
+
}
|
|
13
|
+
function colorize(color, text) {
|
|
14
|
+
return color ? ux.colorize(color, text) : text;
|
|
15
|
+
}
|
|
16
|
+
// SOURCE? is a best-effort guess, kept visually uncertain with a trailing "?" unless `confirmed`
|
|
17
|
+
// (the guessed plugin's own source was found to reference the name), which drops the "?".
|
|
18
|
+
function sourceCell(option) {
|
|
19
|
+
if (!option.guess)
|
|
20
|
+
return '';
|
|
21
|
+
return colorize(confidenceColor(option), option.confirmed ? option.guess : `${option.guess}?`);
|
|
22
|
+
}
|
|
23
|
+
function pluginCell(option) {
|
|
24
|
+
return option.pluginName ? colorize(confidenceColor(option), option.pluginName) : '';
|
|
25
|
+
}
|
|
26
|
+
function coreCell(option) {
|
|
27
|
+
return option.core ? ux.colorize('cyan', 'yes') : '';
|
|
28
|
+
}
|
|
29
|
+
// Spells out what the colors mean once, up front, rather than leaving green/yellow (and cyan when
|
|
30
|
+
// CORE is shown) to be decoded from the table alone.
|
|
31
|
+
function renderLegend(includeCore) {
|
|
32
|
+
const items = [`${ux.colorize('green', '■')} confirmed`, `${ux.colorize('yellow', '■')} guessed, unconfirmed (?)`];
|
|
33
|
+
if (includeCore)
|
|
34
|
+
items.push(`${ux.colorize('cyan', '■')} WordPress core`);
|
|
35
|
+
return items.join(' '.repeat(3));
|
|
36
|
+
}
|
|
37
|
+
// AUTOLOAD/CORE deliberately get no `width` below, leaving them 'auto' (content-sized): tty-table
|
|
38
|
+
// has a real bug in its fixed-width path (confirmed against its own source, format.js's
|
|
39
|
+
// getColumnWidths) where `result = column.width; result = result + config.GUTTER` string-
|
|
40
|
+
// concatenates a width like "11" with the gutter number instead of adding them ("11" + 1 ===
|
|
41
|
+
// "111"), massively overshooting. 'auto' does the arithmetic correctly and is exactly what these
|
|
42
|
+
// two columns need anyway: their own header ("AUTOLOAD"/"CORE") is the longest thing they ever
|
|
43
|
+
// have to display, at 11 and 7 chars respectively (text + 2 padding + 1 gutter).
|
|
44
|
+
const AUTO_COLUMN_CHARS = { autoload: 11, core: 7 };
|
|
45
|
+
// Relative weights, not literal percentages (normalized below): NAME varies most (plugin slugs
|
|
46
|
+
// and cache keys can run long) so it gets the largest share of whatever's left once AUTOLOAD/CORE
|
|
47
|
+
// take their fixed chars.
|
|
48
|
+
const COLUMN_WEIGHT = { name: 40, plugin: 24, source: 28 };
|
|
49
|
+
// Each percentage column also costs 2 chars of padding + 1 gutter beyond its own percentage share
|
|
50
|
+
// (tty-table adds those on top, not out of it); NAME/SOURCE?/PLUGIN are always all three present.
|
|
51
|
+
const PERCENTAGE_COLUMN_OVERHEAD = 3 * 3;
|
|
52
|
+
// Columns stay content-sized by default (tty-table only shrinks to fit, never grows), which left
|
|
53
|
+
// the table narrow with a lot of unused terminal width. Assigning NAME/SOURCE?/PLUGIN an explicit
|
|
54
|
+
// percentage of the terminal forces them to fill it instead. The percentage has to be computed
|
|
55
|
+
// against the actual available width, not a flat constant: a flat "88%" left enough room at a
|
|
56
|
+
// wide terminal but, combined with AUTOLOAD/CORE's own fixed chars and the padding/gutter overhead
|
|
57
|
+
// above, overran an ordinary ~100-column one, and tty-table's shrink-to-fit response shrank
|
|
58
|
+
// AUTOLOAD/CORE below their own header text, truncating "AUTOLOAD" to "AUTOL…" (found testing at
|
|
59
|
+
// exactly that width).
|
|
60
|
+
function fillPercent(width, includeCore) {
|
|
61
|
+
const reserved = AUTO_COLUMN_CHARS.autoload + (includeCore ? AUTO_COLUMN_CHARS.core : 0) + PERCENTAGE_COLUMN_OVERHEAD + 4; // +4: slim right margin
|
|
62
|
+
return Math.max(50, ((width - reserved) / width) * 100);
|
|
63
|
+
}
|
|
64
|
+
function columnWidth(weight, width, includeCore) {
|
|
65
|
+
const total = COLUMN_WEIGHT.name + COLUMN_WEIGHT.source + COLUMN_WEIGHT.plugin;
|
|
66
|
+
return `${Math.round((weight / total) * fillPercent(width, includeCore))}%`;
|
|
67
|
+
}
|
|
68
|
+
// tty-table sizes columns against process.stdout.columns when present (a real terminal, correctly
|
|
69
|
+
// left alone: a genuinely narrow window should wrap). Piped/redirected output and this process
|
|
70
|
+
// itself (no TTY) has neither that nor $COLUMNS, so the library falls back to a hardcoded 80 and
|
|
71
|
+
// squeezes every cell into broken multi-line wrapping regardless of any width option passed in,
|
|
72
|
+
// confirmed against its own source (getAvailableWidth() in format.js only reads config.width at
|
|
73
|
+
// all when one of the two is set). A generous $COLUMNS makes that fallback path wide instead.
|
|
74
|
+
const NON_TTY_FALLBACK_COLUMNS = 300;
|
|
75
|
+
// Bordered table via tty-table (an oclif-recommended table library, see
|
|
76
|
+
// https://oclif.io/docs/user_experience). CORE is a fact (matched against WordPress's own
|
|
77
|
+
// install-time defaults), hidden entirely under --no-core rather than left as an all-blank
|
|
78
|
+
// column. See confidenceColor() above for SOURCE?/PLUGIN's shared sûr/pas sûr color coding.
|
|
79
|
+
function renderTable(rows, { includeCore }) {
|
|
80
|
+
const previousColumns = process.env.COLUMNS;
|
|
81
|
+
if (!process.stdout.columns)
|
|
82
|
+
process.env.COLUMNS = String(NON_TTY_FALLBACK_COLUMNS);
|
|
83
|
+
// Mirrors tty-table's own getAvailableWidth(): its column-width percentages are computed against
|
|
84
|
+
// this same number, so fillPercent() above needs it too to reserve the right amount for AUTOLOAD/CORE.
|
|
85
|
+
const width = (process.stdout.columns || Number(process.env.COLUMNS) || NON_TTY_FALLBACK_COLUMNS) - 2;
|
|
86
|
+
const header = [
|
|
87
|
+
{ align: 'left', headerAlign: 'left', value: 'NAME', width: columnWidth(COLUMN_WEIGHT.name, width, includeCore) },
|
|
88
|
+
{ align: 'left', headerAlign: 'left', value: 'AUTOLOAD' },
|
|
89
|
+
];
|
|
90
|
+
if (includeCore)
|
|
91
|
+
header.push({ align: 'left', headerAlign: 'left', value: 'CORE' });
|
|
92
|
+
header.push({ align: 'left', headerAlign: 'left', value: 'SOURCE?', width: columnWidth(COLUMN_WEIGHT.source, width, includeCore) }, { align: 'left', headerAlign: 'left', value: 'PLUGIN', width: columnWidth(COLUMN_WEIGHT.plugin, width, includeCore) });
|
|
93
|
+
const body = rows.map((row) => {
|
|
94
|
+
const cells = [row.name, row.autoload];
|
|
95
|
+
if (includeCore)
|
|
96
|
+
cells.push(coreCell(row));
|
|
97
|
+
cells.push(sourceCell(row), pluginCell(row));
|
|
98
|
+
return cells;
|
|
99
|
+
});
|
|
100
|
+
try {
|
|
101
|
+
// truncate (rather than the default wrap) so an oversized cell, like a WPForms cache key
|
|
102
|
+
// seen live running ~140 chars, ellipsizes on one line instead of blowing up the row height.
|
|
103
|
+
return ttyTable(header, body, { borderStyle: 'solid', compact: true, truncate: '…' }).render();
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
if (previousColumns === undefined)
|
|
107
|
+
delete process.env.COLUMNS;
|
|
108
|
+
else
|
|
109
|
+
process.env.COLUMNS = previousColumns;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export default class List extends LoopressCommand {
|
|
113
|
+
static description = 'List WordPress option names currently on the site (names and autoload only, never values). ' +
|
|
114
|
+
'CORE flags a WordPress-native default (certain); SOURCE? is a best-effort guessed plugin slug, found by name or by ' +
|
|
115
|
+
'scanning active plugins\' own PHP source (uncertain unless confirmed, occasionally wrong or blank, never trust it over CORE); ' +
|
|
116
|
+
'PLUGIN is that slug\'s own declared display name, a label only, no confidence of its own. ' +
|
|
117
|
+
'Use this to find the name of the option you want, then `lps option add <name>` to track it.';
|
|
118
|
+
static enableJsonFlag = true;
|
|
119
|
+
static examples = ['$ lps option list', '$ lps option list --no-core'];
|
|
120
|
+
static flags = {
|
|
121
|
+
'no-core': Flags.boolean({
|
|
122
|
+
description: 'Exclude WordPress-native default options and the CORE column from the output, including --json',
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
async run() {
|
|
126
|
+
const { flags } = await this.parse(List);
|
|
127
|
+
// Every call scans active plugins' own PHP source for names the naming guess missed (not
|
|
128
|
+
// opt-in, roughly 1-3s on a real site once vendor/tests/languages are pruned from the scan;
|
|
129
|
+
// see OptionsService::listOptionNames() server-side), hence the spinner unconditionally.
|
|
130
|
+
ux.action.start('Fetching and verifying options');
|
|
131
|
+
let options;
|
|
132
|
+
try {
|
|
133
|
+
options = await this.wp.get(OPTIONS_ENDPOINT);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
ux.action.stop();
|
|
137
|
+
}
|
|
138
|
+
if (flags['no-core'])
|
|
139
|
+
options = options.filter((option) => !option.core);
|
|
140
|
+
if (options.length === 0) {
|
|
141
|
+
this.log('No options found');
|
|
142
|
+
return options;
|
|
143
|
+
}
|
|
144
|
+
const includeCore = !flags['no-core'];
|
|
145
|
+
this.log(`Found ${pluralize(options.length, 'option')}:\n`);
|
|
146
|
+
this.log(renderLegend(includeCore));
|
|
147
|
+
this.log('');
|
|
148
|
+
this.log(renderTable(options, { includeCore }));
|
|
149
|
+
return options;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
-
type
|
|
3
|
-
id: number;
|
|
4
|
-
title: string;
|
|
5
|
-
};
|
|
6
|
-
type PullResult = {
|
|
7
|
-
orphans: string[];
|
|
8
|
-
pulled: PulledPage[];
|
|
9
|
-
skipped: number;
|
|
10
|
-
status: 'dry-run' | 'success';
|
|
11
|
-
};
|
|
2
|
+
import { type LocalOption } from '../../utils/option-format.js';
|
|
12
3
|
export default class Pull extends LoopressCommand {
|
|
13
4
|
static args: {
|
|
14
5
|
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
@@ -20,6 +11,6 @@ export default class Pull extends LoopressCommand {
|
|
|
20
11
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
21
12
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
22
13
|
};
|
|
23
|
-
run(): Promise<
|
|
14
|
+
run(): Promise<LocalOption[]>;
|
|
15
|
+
private pullOption;
|
|
24
16
|
}
|
|
25
|
-
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
5
|
+
import { loadFiles } from '../../lib/load-files.js';
|
|
6
|
+
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
7
|
+
import { optionEndpoint, optionFileName, parseLocalOption } from '../../utils/option-format.js';
|
|
8
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
9
|
+
export default class Pull extends LoopressCommand {
|
|
10
|
+
static args = {
|
|
11
|
+
path: Args.string({ description: 'Path to options directory (overrides project config)' }),
|
|
12
|
+
};
|
|
13
|
+
static description = 'Refresh locally tracked options from WordPress. Options not yet tracked are never pulled, run `lps option add <name>` for those first.';
|
|
14
|
+
static enableJsonFlag = true;
|
|
15
|
+
static examples = ['$ lps option pull'];
|
|
16
|
+
static flags = {
|
|
17
|
+
...LoopressCommand.dryRunFlag,
|
|
18
|
+
...LoopressCommand.yesFlag,
|
|
19
|
+
};
|
|
20
|
+
async run() {
|
|
21
|
+
const { args } = await this.parse(Pull);
|
|
22
|
+
const dir = this.resolveOptionsPath(args.path);
|
|
23
|
+
const { url } = this.siteConfig;
|
|
24
|
+
this.log(`Pulling tracked options from ${url}`);
|
|
25
|
+
this.log(`Options path: ${dir}`);
|
|
26
|
+
const tracked = await loadFiles(dir, {
|
|
27
|
+
extension: '.json',
|
|
28
|
+
onSkip: (message) => {
|
|
29
|
+
this.warn(message);
|
|
30
|
+
},
|
|
31
|
+
parse: parseLocalOption,
|
|
32
|
+
});
|
|
33
|
+
if (tracked.length === 0) {
|
|
34
|
+
this.log('Nothing tracked locally. Run `lps option add <name>` first.');
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
if (this.dryRun) {
|
|
38
|
+
this.log(`[dry-run] Would refresh ${pluralize(tracked.length, 'option')}: ${tracked.map((option) => option.name).join(', ')}`);
|
|
39
|
+
return tracked;
|
|
40
|
+
}
|
|
41
|
+
const refreshed = [];
|
|
42
|
+
const orphans = [];
|
|
43
|
+
for (const option of tracked) {
|
|
44
|
+
const result = await this.pullOption(dir, option);
|
|
45
|
+
if (result === null) {
|
|
46
|
+
orphans.push(optionFileName(option.name));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
refreshed.push(result);
|
|
50
|
+
this.log(`Pulled: ${option.name}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await this.removeOrphanedFiles(dir, orphans, 'no longer exist on WordPress');
|
|
54
|
+
this.log(`Pulled ${pluralize(refreshed.length, 'option')}`);
|
|
55
|
+
return refreshed;
|
|
56
|
+
}
|
|
57
|
+
// Returns the refreshed option (and writes it to disk), or null when the tracked name is a
|
|
58
|
+
// 404 on this environment: absent, not a failure (see the orphan reconciliation in run()).
|
|
59
|
+
// readonly is a local policy flag with no WordPress counterpart, always preserved from the
|
|
60
|
+
// existing file rather than overwritten by the pulled value.
|
|
61
|
+
async pullOption(dir, option) {
|
|
62
|
+
let remote;
|
|
63
|
+
try {
|
|
64
|
+
remote = await this.wp.get(optionEndpoint(option.name));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (isNotFoundError(error))
|
|
68
|
+
return null;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
const local = { ...remote, readonly: option.readonly };
|
|
72
|
+
await writeFile(join(dir, optionFileName(option.name)), JSON.stringify(local, null, 2) + '\n');
|
|
73
|
+
return local;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
-
type PushedPage = {
|
|
3
|
-
id: null | number;
|
|
4
|
-
title: string;
|
|
5
|
-
};
|
|
6
2
|
type PushResult = {
|
|
7
|
-
pushed:
|
|
3
|
+
pushed: string[];
|
|
4
|
+
skipped: string[];
|
|
8
5
|
status: 'dry-run' | 'success';
|
|
9
6
|
};
|
|
10
7
|
export default class Push extends PushCommand {
|
|
@@ -19,8 +16,6 @@ export default class Push extends PushCommand {
|
|
|
19
16
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
20
17
|
};
|
|
21
18
|
run(): Promise<PushResult>;
|
|
22
|
-
private
|
|
23
|
-
private loadFiles;
|
|
24
|
-
private pushPage;
|
|
19
|
+
private pushOption;
|
|
25
20
|
}
|
|
26
21
|
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
2
|
+
import { loadFiles } from '../../lib/load-files.js';
|
|
3
|
+
import { PushCommand } from '../../lib/push-command.js';
|
|
4
|
+
import { optionEndpoint, parseLocalOption, partitionByReadonly } from '../../utils/option-format.js';
|
|
5
|
+
import { pluralize } from '../../utils/pluralize.js';
|
|
6
|
+
export default class Push extends PushCommand {
|
|
7
|
+
static args = {
|
|
8
|
+
path: Args.string({ description: 'Path to options directory (overrides project config)' }),
|
|
9
|
+
};
|
|
10
|
+
static description = 'Push locally tracked, non-readonly options to WordPress (upsert only, never deletes an untracked option). ' +
|
|
11
|
+
'Options marked "readonly": true in their local file are skipped, edit the file to override.';
|
|
12
|
+
static enableJsonFlag = true;
|
|
13
|
+
static examples = ['$ lps option push'];
|
|
14
|
+
static flags = {
|
|
15
|
+
...PushCommand.dryRunFlag,
|
|
16
|
+
...PushCommand.yesFlag,
|
|
17
|
+
};
|
|
18
|
+
async run() {
|
|
19
|
+
const { args } = await this.parse(Push);
|
|
20
|
+
const dir = this.resolveOptionsPath(args.path);
|
|
21
|
+
const { url } = this.siteConfig;
|
|
22
|
+
this.log(`Pushing tracked options to ${url}`);
|
|
23
|
+
this.log(`Options path: ${dir}`);
|
|
24
|
+
const tracked = await loadFiles(dir, {
|
|
25
|
+
extension: '.json',
|
|
26
|
+
onSkip: (message) => {
|
|
27
|
+
this.warn(message);
|
|
28
|
+
},
|
|
29
|
+
parse: parseLocalOption,
|
|
30
|
+
});
|
|
31
|
+
const { skipped, writable } = partitionByReadonly(tracked);
|
|
32
|
+
if (skipped.length > 0) {
|
|
33
|
+
this.log(`Skipping ${pluralize(skipped.length, 'readonly option')}: ${skipped.map((option) => option.name).join(', ')}`);
|
|
34
|
+
}
|
|
35
|
+
this.log(`Found ${pluralize(writable.length, 'option')} to push`);
|
|
36
|
+
const pushed = [];
|
|
37
|
+
await this.runPushTasks(writable, (option) => option.name, async (option, task) => {
|
|
38
|
+
await this.pushOption(option, task);
|
|
39
|
+
pushed.push(option.name);
|
|
40
|
+
});
|
|
41
|
+
if (this.failedCount > 0) {
|
|
42
|
+
this.error(`${pluralize(this.failedCount, 'option')} failed to push.`);
|
|
43
|
+
}
|
|
44
|
+
const skippedNames = skipped.map((option) => option.name);
|
|
45
|
+
if (this.dryRun)
|
|
46
|
+
return { pushed, skipped: skippedNames, status: 'dry-run' };
|
|
47
|
+
await this.recordSuccess();
|
|
48
|
+
this.log('All options pushed.');
|
|
49
|
+
return { pushed, skipped: skippedNames, status: 'success' };
|
|
50
|
+
}
|
|
51
|
+
async pushOption(option, task) {
|
|
52
|
+
if (this.dryRun) {
|
|
53
|
+
if (task)
|
|
54
|
+
task.output = `[dry-run] Would push: ${option.name}`;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
await this.wp.put(optionEndpoint(option.name), { autoload: option.autoload, value: option.value });
|
|
59
|
+
if (task)
|
|
60
|
+
task.output = `Pushed: ${option.name}`;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
this.reportTaskFailure(`Failed to push ${option.name}: ${error.message}`, error, task);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
type RemoveResult = {
|
|
3
|
+
deletedRemote: boolean;
|
|
4
|
+
name: string;
|
|
5
|
+
status: 'aborted' | 'dry-run' | 'success';
|
|
6
|
+
};
|
|
7
|
+
export default class Remove extends LoopressCommand {
|
|
8
|
+
static args: {
|
|
9
|
+
name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
10
|
+
};
|
|
11
|
+
static description: string;
|
|
12
|
+
static enableJsonFlag: boolean;
|
|
13
|
+
static examples: string[];
|
|
14
|
+
static flags: {
|
|
15
|
+
'local-only': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
path: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
|
+
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
|
+
};
|
|
20
|
+
run(): Promise<RemoveResult>;
|
|
21
|
+
}
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { Args, Flags } from '@oclif/core';
|
|
3
|
+
import { rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
6
|
+
import { guardProductionPush } from '../../lib/guard-production-push.js';
|
|
7
|
+
import { isInteractive } from '../../lib/interactive.js';
|
|
8
|
+
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
9
|
+
import { optionEndpoint, optionFileName } from '../../utils/option-format.js';
|
|
10
|
+
export default class Remove extends LoopressCommand {
|
|
11
|
+
static args = {
|
|
12
|
+
name: Args.string({ description: 'Option name to stop tracking', required: true }),
|
|
13
|
+
};
|
|
14
|
+
static description = 'Stop tracking an option locally and delete it from WordPress. Local untracking always happens (git-reversible); ' +
|
|
15
|
+
'pass --local-only to skip the WordPress delete and only stop tracking.';
|
|
16
|
+
static enableJsonFlag = true;
|
|
17
|
+
static examples = ['$ lps option remove wpseo_titles', '$ lps option remove wpseo_titles --local-only'];
|
|
18
|
+
static flags = {
|
|
19
|
+
...LoopressCommand.dryRunFlag,
|
|
20
|
+
...LoopressCommand.yesFlag,
|
|
21
|
+
'local-only': Flags.boolean({ description: 'Untrack locally without deleting the option on WordPress' }),
|
|
22
|
+
path: Flags.string({ description: 'Path to options directory (overrides project config)' }),
|
|
23
|
+
};
|
|
24
|
+
async run() {
|
|
25
|
+
const { args, flags } = await this.parse(Remove);
|
|
26
|
+
const { name } = args;
|
|
27
|
+
const dir = this.resolveOptionsPath(flags.path);
|
|
28
|
+
const { url } = this.siteConfig;
|
|
29
|
+
const file = join(dir, optionFileName(name));
|
|
30
|
+
const localOnly = flags['local-only'];
|
|
31
|
+
if (this.dryRun) {
|
|
32
|
+
const andDelete = localOnly ? '' : ` and delete it from ${url}`;
|
|
33
|
+
this.log(`[dry-run] Would untrack "${name}" locally${andDelete}`);
|
|
34
|
+
return { deletedRemote: false, name, status: 'dry-run' };
|
|
35
|
+
}
|
|
36
|
+
if (localOnly) {
|
|
37
|
+
await rm(file, { force: true });
|
|
38
|
+
this.log(`Untracked "${name}" locally (left untouched on ${url})`);
|
|
39
|
+
return { deletedRemote: false, name, status: 'success' };
|
|
40
|
+
}
|
|
41
|
+
await guardProductionPush({
|
|
42
|
+
dryRun: this.dryRun,
|
|
43
|
+
error: (message) => this.error(message),
|
|
44
|
+
siteConfig: this.siteConfig,
|
|
45
|
+
yes: this.yes,
|
|
46
|
+
});
|
|
47
|
+
if (!this.yes && isInteractive()) {
|
|
48
|
+
const isProceed = await confirm({ default: false, message: `Delete option "${name}" from ${url}? (local tracking also stops)` });
|
|
49
|
+
if (!isProceed) {
|
|
50
|
+
this.log('Aborted.');
|
|
51
|
+
return { deletedRemote: false, name, status: 'aborted' };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
let deletedRemote = true;
|
|
55
|
+
try {
|
|
56
|
+
await this.wp.delete(optionEndpoint(name));
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (!isNotFoundError(error))
|
|
60
|
+
throw error;
|
|
61
|
+
deletedRemote = false;
|
|
62
|
+
}
|
|
63
|
+
await rm(file, { force: true });
|
|
64
|
+
this.log(deletedRemote
|
|
65
|
+
? `Removed "${name}" from ${url} and untracked it locally`
|
|
66
|
+
: `"${name}" was already absent on ${url}; untracked it locally`);
|
|
67
|
+
return { deletedRemote, name, status: 'success' };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { confirm } from '@inquirer/prompts';
|
|
2
1
|
import { Flags } from '@oclif/core';
|
|
3
2
|
import { existsSync } from 'node:fs';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
|
-
import {
|
|
4
|
+
import { confirmUninstall } from '../../lib/interactive.js';
|
|
6
5
|
import { PushCommand } from '../../lib/push-command.js';
|
|
7
6
|
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
8
7
|
import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS } from '../../utils/plugin-sync.js';
|
|
@@ -55,7 +54,8 @@ export default class Push extends PushCommand {
|
|
|
55
54
|
if (this.dryRun) {
|
|
56
55
|
return this.result(diff, { includeCollisions: true, pruned: toPrune, removed: diff.toRemove, status: 'dry-run' });
|
|
57
56
|
}
|
|
58
|
-
await
|
|
57
|
+
if (!(await confirmUninstall(diff.toRemove, this.yes)))
|
|
58
|
+
this.error('Aborted.');
|
|
59
59
|
const deactivated = await this.deactivateEndangered(installed, diff, force);
|
|
60
60
|
// Plugins that must end up active: the ones we deactivated for the file swap (minus any
|
|
61
61
|
// being uninstalled) plus the ones the manifest wants active but that are inactive today.
|
|
@@ -92,13 +92,6 @@ export default class Push extends PushCommand {
|
|
|
92
92
|
await this.wp.put(`wp/v2/plugins/${plugin.file}`, { status: 'active' });
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
-
async confirmRemovals(toRemove) {
|
|
96
|
-
if (toRemove.length === 0 || this.yes || !isInteractive())
|
|
97
|
-
return;
|
|
98
|
-
const ok = await confirm({ default: false, message: `Uninstall ${toRemove.join(', ')} from the site?` });
|
|
99
|
-
if (!ok)
|
|
100
|
-
this.error('Aborted.');
|
|
101
|
-
}
|
|
102
95
|
async deactivate(plugins) {
|
|
103
96
|
for (const plugin of plugins) {
|
|
104
97
|
this.log(` ⊘ deactivating ${plugin.slug}`);
|
package/dist/commands/pull.js
CHANGED
|
@@ -8,13 +8,14 @@ const PULL_TARGETS = [
|
|
|
8
8
|
{ commandId: 'plugin:pull', label: 'plugins' },
|
|
9
9
|
{ commandId: 'acf:pull', label: 'ACF', supportsYes: true },
|
|
10
10
|
{ commandId: 'api:pull', label: 'API routes', supportsYes: true },
|
|
11
|
+
{ commandId: 'hook:pull', label: 'hooks', supportsYes: true },
|
|
11
12
|
{ commandId: 'form:pull', label: 'forms', supportsYes: true },
|
|
12
|
-
{ commandId: 'page:pull', label: 'pages', supportsYes: true },
|
|
13
13
|
{ commandId: 'seo:pull', label: 'SEO', supportsYes: true },
|
|
14
|
+
{ commandId: 'option:pull', label: 'options', supportsYes: true },
|
|
14
15
|
{ commandId: 'snippet:pull', label: 'snippets', supportsYes: true },
|
|
15
16
|
];
|
|
16
17
|
export default class Pull extends LoopressCommand {
|
|
17
|
-
static description = 'Pull all content, plugins, composer dependencies, ACF, API routes,
|
|
18
|
+
static description = 'Pull all content, plugins, composer dependencies, ACF, API routes, hooks, forms, SEO, options, and snippets, from WordPress';
|
|
18
19
|
static examples = ['$ lps pull', '$ lps pull --env staging', '$ lps pull --dry-run'];
|
|
19
20
|
static flags = {
|
|
20
21
|
...LoopressCommand.dryRunFlag,
|
package/dist/commands/push.js
CHANGED
|
@@ -8,13 +8,14 @@ const PUSH_TARGETS = [
|
|
|
8
8
|
{ commandId: 'composer:push', label: 'composer' },
|
|
9
9
|
{ commandId: 'acf:push', label: 'ACF' },
|
|
10
10
|
{ commandId: 'api:push', label: 'API routes' },
|
|
11
|
+
{ commandId: 'hook:push', label: 'hooks' },
|
|
11
12
|
{ commandId: 'form:push', label: 'forms' },
|
|
12
|
-
{ commandId: 'page:push', label: 'pages' },
|
|
13
13
|
{ commandId: 'seo:push', label: 'SEO' },
|
|
14
|
+
{ commandId: 'option:push', label: 'options' },
|
|
14
15
|
{ commandId: 'snippet:push', label: 'snippets' },
|
|
15
16
|
];
|
|
16
17
|
export default class Push extends LoopressCommand {
|
|
17
|
-
static description = 'Push all local content, plugins, composer dependencies, ACF, API routes,
|
|
18
|
+
static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, SEO, options, and snippets, to WordPress';
|
|
18
19
|
static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
|
|
19
20
|
static flags = {
|
|
20
21
|
...LoopressCommand.dryRunFlag,
|
|
@@ -42,7 +43,7 @@ export default class Push extends LoopressCommand {
|
|
|
42
43
|
}
|
|
43
44
|
// Every delegated push always gets --yes: either this command's own guard already confirmed
|
|
44
45
|
// production once above, or the target isn't production and there's nothing to confirm.
|
|
45
|
-
// Without this, each
|
|
46
|
+
// Without this, each delegated command would re-run its own production guard.
|
|
46
47
|
buildArgv() {
|
|
47
48
|
const argv = ['--env', this.siteConfig.name, '--yes'];
|
|
48
49
|
if (this.dryRun)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { resourceDiffCommand } from '../../lib/diff-command.js';
|
|
2
|
+
export default resourceDiffCommand('seo', {
|
|
3
|
+
description: 'Show what differs in SEO settings, post meta, and redirects between your local files and a WordPress environment, or between two environments',
|
|
4
|
+
pathNoun: 'SEO directory',
|
|
5
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { resourceDiffCommand } from '../../lib/diff-command.js';
|
|
2
|
+
export default resourceDiffCommand('snippet', {
|
|
3
|
+
description: 'Show what differs in snippets between your local files and a WordPress environment, or between two environments',
|
|
4
|
+
pathNoun: 'snippets directory',
|
|
5
|
+
});
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { confirm } from '@inquirer/prompts';
|
|
2
1
|
import { Flags } from '@oclif/core';
|
|
3
2
|
import { existsSync } from 'node:fs';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
|
-
import {
|
|
4
|
+
import { confirmUninstall } from '../../lib/interactive.js';
|
|
6
5
|
import { PushCommand } from '../../lib/push-command.js';
|
|
7
6
|
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
8
7
|
import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS } from '../../utils/plugin-sync.js';
|
|
@@ -65,7 +64,8 @@ export default class Push extends PushCommand {
|
|
|
65
64
|
this.log(` ! ${c.slug} (take over)`);
|
|
66
65
|
if (this.dryRun)
|
|
67
66
|
return result(diff, 'dry-run', diff.toRemove, true);
|
|
68
|
-
await
|
|
67
|
+
if (!(await confirmUninstall(diff.toRemove, this.yes)))
|
|
68
|
+
this.error('Aborted.');
|
|
69
69
|
const response = await this.sync(manifest, force);
|
|
70
70
|
if (response.output.trim())
|
|
71
71
|
this.log(response.output.trim());
|
|
@@ -73,13 +73,6 @@ export default class Push extends PushCommand {
|
|
|
73
73
|
await this.recordSuccess();
|
|
74
74
|
return result(diff, 'success', response.removed ?? diff.toRemove, force);
|
|
75
75
|
}
|
|
76
|
-
async confirmRemovals(toRemove) {
|
|
77
|
-
if (toRemove.length === 0 || this.yes || !isInteractive())
|
|
78
|
-
return;
|
|
79
|
-
const ok = await confirm({ default: false, message: `Uninstall ${toRemove.join(', ')} from the site?` });
|
|
80
|
-
if (!ok)
|
|
81
|
-
this.error('Aborted.');
|
|
82
|
-
}
|
|
83
76
|
async fetchInstanceLock() {
|
|
84
77
|
try {
|
|
85
78
|
const { composerLock } = await this.wp.get('loopress/v1/composer/lock');
|
package/dist/lib/base.d.ts
CHANGED
|
@@ -35,7 +35,8 @@ export declare abstract class LoopressCommand extends Command {
|
|
|
35
35
|
protected resolveApiPath(override?: string): string;
|
|
36
36
|
protected resolveAppsPath(override?: string): string;
|
|
37
37
|
protected resolveFormPath(override?: string): string;
|
|
38
|
-
protected
|
|
38
|
+
protected resolveHooksPath(override?: string): string;
|
|
39
|
+
protected resolveOptionsPath(override?: string): string;
|
|
39
40
|
protected resolveSeoPath(override?: string): string;
|
|
40
41
|
protected resolveSnippetsPath(override?: string): string;
|
|
41
42
|
private pickEnvironment;
|