@loopress/cli 0.26.0 → 0.28.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 +275 -177
- package/dist/commands/app/pull.js +17 -4
- package/dist/commands/app/push.d.ts +3 -0
- package/dist/commands/app/push.js +29 -5
- package/dist/commands/composer/push.js +1 -19
- package/dist/commands/diff.js +1 -1
- package/dist/commands/init.js +7 -1
- package/dist/commands/page/push.js +2 -2
- package/dist/commands/plugin/add.d.ts +8 -1
- package/dist/commands/plugin/add.js +10 -5
- package/dist/commands/plugin/audit.js +4 -5
- package/dist/commands/plugin/pull.d.ts +3 -1
- package/dist/commands/plugin/pull.js +17 -9
- package/dist/commands/plugin/push.d.ts +5 -1
- package/dist/commands/plugin/push.js +90 -37
- package/dist/commands/plugin/status.d.ts +1 -0
- package/dist/commands/plugin/status.js +21 -2
- package/dist/commands/project/config.js +12 -8
- package/dist/commands/promote.d.ts +2 -0
- package/dist/commands/promote.js +70 -8
- package/dist/commands/pull.js +8 -4
- package/dist/commands/push.d.ts +1 -0
- package/dist/commands/push.js +42 -4
- package/dist/commands/template/diff.d.ts +10 -0
- package/dist/commands/template/diff.js +13 -0
- package/dist/commands/template/list.d.ts +13 -0
- package/dist/commands/template/list.js +23 -0
- package/dist/commands/template/push.d.ts +20 -0
- package/dist/commands/template/push.js +42 -0
- package/dist/commands/theme/add.d.ts +8 -1
- package/dist/commands/theme/add.js +4 -2
- package/dist/commands/theme/pull.d.ts +3 -1
- package/dist/commands/theme/pull.js +10 -4
- package/dist/commands/theme/style/diff.d.ts +2 -0
- package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
- package/dist/commands/{theme-styles → theme/style}/pull.d.ts +1 -1
- package/dist/commands/{theme-styles → theme/style}/pull.js +3 -3
- package/dist/commands/{theme-styles → theme/style}/push.d.ts +1 -1
- package/dist/commands/{theme-styles → theme/style}/push.js +5 -5
- package/dist/commands/theme/style/rollback.d.ts +2 -0
- package/dist/commands/theme/style/rollback.js +6 -0
- package/dist/lib/api-client.js +26 -1
- package/dist/lib/app-build.d.ts +5 -0
- package/dist/lib/app-build.js +69 -0
- package/dist/lib/app-manifest.d.ts +4 -0
- package/dist/lib/app-manifest.js +8 -0
- package/dist/lib/base.js +11 -2
- package/dist/lib/bootstrap-full-install.js +45 -14
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +4 -1
- package/dist/lib/diff-command.d.ts +1 -0
- package/dist/lib/diff-command.js +6 -4
- package/dist/lib/json-delegation.d.ts +1 -0
- package/dist/lib/json-delegation.js +18 -0
- package/dist/lib/load-snippets.js +1 -1
- package/dist/lib/php-files-command.js +4 -3
- package/dist/lib/readdir-tolerant.js +15 -5
- package/dist/lib/resource-state.js +43 -1
- package/dist/lib/rollback-command.d.ts +1 -0
- package/dist/lib/rollback-command.js +8 -6
- package/dist/lib/rotate-app-password.js +2 -2
- package/dist/lib/snapshot-store.js +14 -0
- package/dist/lib/validate-local.js +34 -7
- package/dist/lib/wp-client.d.ts +4 -1
- package/dist/lib/wp-client.js +56 -3
- package/dist/types/plugin.d.ts +8 -1
- package/dist/types/plugin.js +4 -1
- package/dist/types/project-config.generated.d.ts +19 -2
- package/dist/utils/composer.d.ts +14 -0
- package/dist/utils/composer.js +136 -0
- package/dist/utils/loopress-config.d.ts +1 -0
- package/dist/utils/loopress-config.js +14 -0
- package/dist/utils/page-format.d.ts +12 -0
- package/dist/utils/page-format.js +41 -26
- package/dist/utils/plugins.d.ts +9 -4
- package/dist/utils/plugins.js +16 -7
- package/dist/utils/resource-dirs.d.ts +2 -0
- package/dist/utils/resource-dirs.js +4 -0
- package/dist/utils/seo-format.d.ts +2 -2
- package/dist/utils/template-format.d.ts +30 -0
- package/dist/utils/template-format.js +36 -0
- package/oclif.manifest.json +536 -320
- package/package.json +7 -4
- package/schemas/project-config.schema.json +25 -4
- package/dist/commands/theme-styles/diff.d.ts +0 -2
- package/dist/commands/theme-styles/rollback.d.ts +0 -2
- package/dist/commands/theme-styles/rollback.js +0 -5
|
@@ -70,7 +70,7 @@ async function loadSnippet(path, file, onSkip) {
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
// Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
|
|
73
|
-
// (publishes to the Loopress api as a shared source)
|
|
73
|
+
// (publishes to the Loopress api as a shared source): both read the same local `snippets/`
|
|
74
74
|
// directory the same way. `onSkip` is optional so callers that don't care about per-file
|
|
75
75
|
// warnings (e.g. `snippet publish`) can ignore them; skipped files are simply left out either way.
|
|
76
76
|
export async function loadSnippets(path, onSkip) {
|
|
@@ -341,9 +341,10 @@ export const API_FILES_RESOURCE = {
|
|
|
341
341
|
emptyListMessage: 'No API route files found',
|
|
342
342
|
endpoint: 'loopress/v1/api-files',
|
|
343
343
|
// Mirrors ApiFilesController::isValidFilename(): kebab-case segments or a bracketed dynamic
|
|
344
|
-
// segment like [order_id], joined by '/'.
|
|
345
|
-
|
|
346
|
-
|
|
344
|
+
// segment like [order_id], joined by '/', and never a root "index" (api/index.php is
|
|
345
|
+
// reserved; a nested orders/index.php is the /orders route).
|
|
346
|
+
filenamePattern: /^(?!index$)(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/,
|
|
347
|
+
invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens, or a bracketed dynamic segment like "[order_id]" (e.g. "invoice-pdf/[order_id].php"), and a root "index.php" is reserved: name the file, or nest it (orders/index.php is the /orders route)',
|
|
347
348
|
label: 'API routes',
|
|
348
349
|
listDescription: 'List custom API route files from WordPress',
|
|
349
350
|
noun: 'route file',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdir } from 'node:fs/promises';
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
3
|
export async function readdirTolerant(dir, options) {
|
|
4
4
|
try {
|
|
@@ -16,10 +16,20 @@ export async function readdirTolerant(dir, options) {
|
|
|
16
16
|
// never the OS separator: callers match these identities against server-sent filenames
|
|
17
17
|
// (which are always '/'-joined) or embed them in an API payload, so a Windows run must
|
|
18
18
|
// produce the exact same strings as a POSIX one. ENOENT on `dir` itself yields no files,
|
|
19
|
-
// same tolerance as readdirTolerant.
|
|
19
|
+
// same tolerance as readdirTolerant. A symlink to a file counts as that file (a project can
|
|
20
|
+
// link a route in from a shared checkout); a broken one, or one to a directory, is skipped.
|
|
20
21
|
export async function walkFiles(dir) {
|
|
21
22
|
const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
const files = [];
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
const path = join(entry.parentPath, entry.name);
|
|
26
|
+
if (entry.isFile() || (entry.isSymbolicLink() && (await isFile(path))))
|
|
27
|
+
files.push(relative(dir, path).split(sep).join('/'));
|
|
28
|
+
}
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
async function isFile(path) {
|
|
32
|
+
return stat(path)
|
|
33
|
+
.then((s) => s.isFile())
|
|
34
|
+
.catch(() => false);
|
|
25
35
|
}
|
|
@@ -4,9 +4,10 @@ import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js
|
|
|
4
4
|
import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
|
|
5
5
|
import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
|
|
6
6
|
import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
|
|
7
|
-
import { formatPageProblems, PAGES_ENDPOINT, readLocalPages } from '../utils/page-format.js';
|
|
7
|
+
import { formatPageProblems, PAGES_ENDPOINT, readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
|
|
8
8
|
import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
|
|
9
9
|
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
|
|
10
|
+
import { CHILD_THEME_ENDPOINT, parsePartFile, parseTemplateFile } from '../utils/template-format.js';
|
|
10
11
|
import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
|
|
11
12
|
import { loadFiles } from './load-files.js';
|
|
12
13
|
import { loadSnippets } from './load-snippets.js';
|
|
@@ -217,6 +218,45 @@ const pageProvider = {
|
|
|
217
218
|
resource: 'page',
|
|
218
219
|
title: 'Pages',
|
|
219
220
|
};
|
|
221
|
+
// ---- Templates and parts ---------------------------------------------------------------
|
|
222
|
+
// Both live in the Loopress child theme (ChildThemeController), one GET answers for both. A
|
|
223
|
+
// template or part edited in the Site Editor gets `siteEditor: 'customized'` on the remote side
|
|
224
|
+
// only, so it always shows as drift: its database copy is what the site renders, not the file.
|
|
225
|
+
async function childThemeState(wp, kind) {
|
|
226
|
+
const child = await wp.get(CHILD_THEME_ENDPOINT);
|
|
227
|
+
const files = child[kind];
|
|
228
|
+
const state = new Map(files.map(({ slug, ...rest }) => [slug, rest]));
|
|
229
|
+
for (const entry of child.customized) {
|
|
230
|
+
const [dir, slug] = entry.split('/', 2);
|
|
231
|
+
if (dir === kind)
|
|
232
|
+
state.set(slug, { ...state.get(slug), siteEditor: 'customized' });
|
|
233
|
+
}
|
|
234
|
+
return state;
|
|
235
|
+
}
|
|
236
|
+
const templateProvider = {
|
|
237
|
+
dirKind: 'template',
|
|
238
|
+
async local(dir) {
|
|
239
|
+
const { files, problems } = await readLocalHtmlFiles(dir, parseTemplateFile);
|
|
240
|
+
if (problems.length > 0)
|
|
241
|
+
throw new Error(formatPageProblems(problems));
|
|
242
|
+
return new Map(files.map(({ slug, ...rest }) => [slug, rest]));
|
|
243
|
+
},
|
|
244
|
+
remote: async (wp) => childThemeState(wp, 'templates'),
|
|
245
|
+
resource: 'template',
|
|
246
|
+
title: 'Templates',
|
|
247
|
+
};
|
|
248
|
+
const partProvider = {
|
|
249
|
+
dirKind: 'part',
|
|
250
|
+
async local(dir) {
|
|
251
|
+
const { files, problems } = await readLocalHtmlFiles(dir, parsePartFile);
|
|
252
|
+
if (problems.length > 0)
|
|
253
|
+
throw new Error(formatPageProblems(problems));
|
|
254
|
+
return new Map(files.map(({ slug, ...rest }) => [slug, rest]));
|
|
255
|
+
},
|
|
256
|
+
remote: async (wp) => childThemeState(wp, 'parts'),
|
|
257
|
+
resource: 'part',
|
|
258
|
+
title: 'Template parts',
|
|
259
|
+
};
|
|
220
260
|
// ---- SEO --------------------------------------------------------------------------------
|
|
221
261
|
function canonicalRedirect(redirect) {
|
|
222
262
|
return omit(redirect, REDIRECT_VOLATILE_KEYS);
|
|
@@ -426,6 +466,8 @@ export const RESOURCE_STATE_PROVIDERS = [
|
|
|
426
466
|
acfProvider,
|
|
427
467
|
apiProvider,
|
|
428
468
|
hookProvider,
|
|
469
|
+
templateProvider,
|
|
470
|
+
partProvider,
|
|
429
471
|
pageProvider,
|
|
430
472
|
seoProvider,
|
|
431
473
|
menuProvider,
|
|
@@ -16,6 +16,7 @@ export type RollbackResult = {
|
|
|
16
16
|
status: 'dry-run' | 'listed' | 'no-op' | 'success';
|
|
17
17
|
};
|
|
18
18
|
export declare function resourceRollbackCommand(resource: string, options: {
|
|
19
|
+
command?: string;
|
|
19
20
|
description: string;
|
|
20
21
|
pathNoun: string;
|
|
21
22
|
}): typeof LoopressCommand;
|
|
@@ -33,7 +33,9 @@ function indentPatch(patch) {
|
|
|
33
33
|
// anything, this runs the equivalent of `lps <resource> diff` between the environment's current
|
|
34
34
|
// state and the state the original push expected to leave behind, and requires explicit
|
|
35
35
|
// confirmation (the same interactive/`--yes` shape as guardProductionPush) when they differ.
|
|
36
|
+
// `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-styles).
|
|
36
37
|
export function resourceRollbackCommand(resource, options) {
|
|
38
|
+
const command = options.command ?? resource;
|
|
37
39
|
class ResourceRollback extends LoopressCommand {
|
|
38
40
|
static args = {
|
|
39
41
|
path: Args.string({ description: `Path to ${options.pathNoun} (overrides project config)` }),
|
|
@@ -41,10 +43,10 @@ export function resourceRollbackCommand(resource, options) {
|
|
|
41
43
|
static description = options.description;
|
|
42
44
|
static enableJsonFlag = true;
|
|
43
45
|
static examples = [
|
|
44
|
-
`$ lps ${
|
|
45
|
-
`$ lps ${
|
|
46
|
-
`$ lps ${
|
|
47
|
-
`$ lps ${
|
|
46
|
+
`$ lps ${command} rollback`,
|
|
47
|
+
`$ lps ${command} rollback --list`,
|
|
48
|
+
`$ lps ${command} rollback --to 1732000000000`,
|
|
49
|
+
`$ lps ${command} rollback --dry-run`,
|
|
48
50
|
];
|
|
49
51
|
static flags = {
|
|
50
52
|
...LoopressCommand.dryRunFlag,
|
|
@@ -105,7 +107,7 @@ export function resourceRollbackCommand(resource, options) {
|
|
|
105
107
|
// an item present now but absent from the snapshot stays present after "restoring" it.
|
|
106
108
|
if (restoreDiff.removed.length > 0) {
|
|
107
109
|
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 ${
|
|
110
|
+
`(${restoreDiff.removed.join(', ')}): restoring only creates and updates, the same as \`lps ${command} push\`. Remove ${restoreDiff.removed.length === 1 ? 'it' : 'them'} by hand if that's part of undoing the original push.`);
|
|
109
111
|
}
|
|
110
112
|
if (this.dryRun) {
|
|
111
113
|
return { drift: driftSummary, id: snapshot.id, restored: summarize(restoreDiff), status: 'dry-run' };
|
|
@@ -125,7 +127,7 @@ export function resourceRollbackCommand(resource, options) {
|
|
|
125
127
|
// production guard and the drift confirmation above already covered what the delegated
|
|
126
128
|
// push's own guard would ask again, same reasoning as the top-level `lps push`
|
|
127
129
|
// delegating to each resource's push command in commands/push.ts.
|
|
128
|
-
await this.config.runCommand(`${
|
|
130
|
+
await this.config.runCommand(`${command.replaceAll(' ', ':')}:push`, [tmpDir, '--env', this.siteConfig.name, '--yes']);
|
|
129
131
|
}
|
|
130
132
|
finally {
|
|
131
133
|
// A cleanup failure here must never hide a real error from the push above: warn about
|
|
@@ -8,14 +8,14 @@ export function isAppPasswordStale(addedAt) {
|
|
|
8
8
|
// the old one. Reversing this order risks locking the user out if the new one is somehow bad.
|
|
9
9
|
export async function rotateAppPassword(env) {
|
|
10
10
|
const [user] = env.token.split(':', 1);
|
|
11
|
-
const oldClient = new WpClient(env.url, env.token);
|
|
11
|
+
const oldClient = new WpClient(env.url, env.token, env.name);
|
|
12
12
|
const old = await oldClient.get(`${APPLICATION_PASSWORDS_PATH}/introspect`);
|
|
13
13
|
const created = await oldClient.post(APPLICATION_PASSWORDS_PATH, { name: 'Loopress' });
|
|
14
14
|
if (!created.password) {
|
|
15
15
|
throw new Error(`WordPress did not return a password when creating the new application password on ${env.url}.`);
|
|
16
16
|
}
|
|
17
17
|
const newToken = `${user}:${created.password}`;
|
|
18
|
-
const newClient = new WpClient(env.url, newToken);
|
|
18
|
+
const newClient = new WpClient(env.url, newToken, env.name);
|
|
19
19
|
try {
|
|
20
20
|
await newClient.get(`${APPLICATION_PASSWORDS_PATH}/introspect`);
|
|
21
21
|
}
|
|
@@ -49,6 +49,7 @@ export async function writeSnapshot(options) {
|
|
|
49
49
|
const { afterState, beforeState, environment, resource, retain = SNAPSHOT_RETENTION, rootDir } = options;
|
|
50
50
|
const dir = snapshotsDir(rootDir, resource, environment);
|
|
51
51
|
await mkdir(dir, { recursive: true });
|
|
52
|
+
await ignoreCacheDirInGit(rootDir);
|
|
52
53
|
const snapshot = {
|
|
53
54
|
afterState: stateToRecord(afterState),
|
|
54
55
|
beforeState: stateToRecord(beforeState),
|
|
@@ -72,6 +73,19 @@ export async function writeSnapshot(options) {
|
|
|
72
73
|
await pruneSnapshots(rootDir, resource, environment, retain);
|
|
73
74
|
return String(id);
|
|
74
75
|
}
|
|
76
|
+
// `.loopress/` lives inside the user's project and holds the environment's full remote state
|
|
77
|
+
// (snippet code, form definitions, option values): a `git add .` must never pick it up. The
|
|
78
|
+
// directory ignores itself, the way `.pytest_cache` does, instead of relying on every project
|
|
79
|
+
// to add it to its own .gitignore. `wx` so a hand-edited file is never overwritten.
|
|
80
|
+
async function ignoreCacheDirInGit(rootDir) {
|
|
81
|
+
try {
|
|
82
|
+
await writeFile(join(rootDir, '.loopress', '.gitignore'), '# Written by the Loopress CLI: local rollback snapshots, never commit them.\n*\n', { flag: 'wx' });
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (!isEexist(error))
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
75
89
|
// Newest first. A missing snapshots directory (never pushed, or pruned to nothing) reads as
|
|
76
90
|
// "no snapshots", not an error, the same ENOENT tolerance every resource-state provider gives
|
|
77
91
|
// a never-pulled local directory.
|
|
@@ -1,22 +1,28 @@
|
|
|
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 { parseLocalOption } from '../utils/option-format.js';
|
|
4
5
|
import { readLocalPages } from '../utils/page-format.js';
|
|
5
6
|
import { parseType } from '../utils/snippet-format.js';
|
|
7
|
+
import { readLocalTemplates } from '../utils/template-format.js';
|
|
6
8
|
import { loadSnippets } from './load-snippets.js';
|
|
7
9
|
import { readdirTolerant } from './readdir-tolerant.js';
|
|
8
10
|
// Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
|
|
9
|
-
// and api routes (`.php`, not JSON) are checked separately.
|
|
10
|
-
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir', 'menuDir'];
|
|
11
|
+
// and api routes (`.php`, not JSON) are checked separately, options too (a stricter shape).
|
|
12
|
+
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir', 'menuDir', 'themeStylesDir'];
|
|
11
13
|
const DEFAULT_DIR = {
|
|
12
14
|
acfDir: 'acf',
|
|
13
15
|
apiDir: 'api',
|
|
14
16
|
formDir: 'forms',
|
|
15
17
|
hooksDir: 'hooks',
|
|
16
18
|
menuDir: 'menus',
|
|
19
|
+
optionsDir: 'options',
|
|
17
20
|
pageDir: 'pages',
|
|
21
|
+
partDir: 'parts',
|
|
18
22
|
seoDir: 'seo',
|
|
19
23
|
snippetsDir: 'snippets',
|
|
24
|
+
templateDir: 'templates',
|
|
25
|
+
themeStylesDir: 'theme',
|
|
20
26
|
};
|
|
21
27
|
// Inspects the local tracked files under `cwd` without contacting WordPress: JSON files parse
|
|
22
28
|
// and have the right shape, snippet sidecars use a known type, `loopress.json` points at a
|
|
@@ -37,12 +43,18 @@ export async function validateLocal(cwd) {
|
|
|
37
43
|
for (const key of JSON_RESOURCE_DIRS) {
|
|
38
44
|
checked += await checkJsonDir(resolve(key), problems);
|
|
39
45
|
}
|
|
46
|
+
// Same reader `option push` uses: a file missing "name", "autoload" or "value" is skipped
|
|
47
|
+
// there with a warning, so it's a problem here.
|
|
48
|
+
checked += await checkJsonDir(resolve('optionsDir'), problems, parseLocalOption);
|
|
40
49
|
checked += await checkSnippets(resolve('snippetsDir'), problems);
|
|
41
50
|
checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty');
|
|
42
51
|
checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty');
|
|
43
52
|
const pages = await readLocalPages(resolve('pageDir'));
|
|
44
53
|
checked += pages.pages.length + pages.problems.length;
|
|
45
54
|
problems.push(...pages.problems);
|
|
55
|
+
const templates = await readLocalTemplates(resolve('templateDir'), resolve('partDir'));
|
|
56
|
+
checked += templates.templates.length + templates.parts.length + templates.problems.length;
|
|
57
|
+
problems.push(...templates.problems);
|
|
46
58
|
return { checked, problems, valid: problems.length === 0 };
|
|
47
59
|
}
|
|
48
60
|
// Recursive: both api/ and hooks/ files can live in a nested path-segment directory
|
|
@@ -91,15 +103,23 @@ async function checkComposerJson(cwd, problems) {
|
|
|
91
103
|
}
|
|
92
104
|
return 1;
|
|
93
105
|
}
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
// Recursive: ACF keeps one subdirectory per object type (acf/field-groups/*.json) and SEO its
|
|
107
|
+
// post meta and redirects (seo/post-meta/<type>/*.json, seo/redirects/*.json); a top-level-only
|
|
108
|
+
// scan silently never checked any of those. `shape` runs on files that are JSON objects and
|
|
109
|
+
// throws to report a resource-specific problem.
|
|
110
|
+
async function checkJsonDir(dir, problems, shape) {
|
|
111
|
+
const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
|
|
96
112
|
let checked = 0;
|
|
97
|
-
for (const
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (!entry.isFile() || !entry.name.endsWith('.json'))
|
|
115
|
+
continue;
|
|
98
116
|
checked += 1;
|
|
99
|
-
|
|
117
|
+
// parentPath, not dir: see checkPhpDir.
|
|
118
|
+
const filePath = join(entry.parentPath, entry.name);
|
|
119
|
+
const raw = await readFile(filePath, 'utf8');
|
|
100
120
|
let parsed;
|
|
101
121
|
try {
|
|
102
|
-
parsed = JSON.parse(
|
|
122
|
+
parsed = JSON.parse(raw);
|
|
103
123
|
}
|
|
104
124
|
catch (error) {
|
|
105
125
|
problems.push({ file: filePath, message: `not valid JSON: ${error.message}` });
|
|
@@ -107,6 +127,13 @@ async function checkJsonDir(dir, problems) {
|
|
|
107
127
|
}
|
|
108
128
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
109
129
|
problems.push({ file: filePath, message: 'expected a JSON object' });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
shape?.(raw);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
problems.push({ file: filePath, message: error.message });
|
|
110
137
|
}
|
|
111
138
|
}
|
|
112
139
|
return checked;
|
package/dist/lib/wp-client.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export declare const REQUEST_TIMEOUT_MS = 30000;
|
|
2
|
+
export declare const LOCAL_ENVIRONMENT_NAME = "local";
|
|
3
|
+
export declare function isLocalEnvironment(environmentName: string | undefined): boolean;
|
|
4
|
+
export declare function assertSecureSiteUrl(siteUrl: string, environmentName: string | undefined): void;
|
|
2
5
|
/**
|
|
3
6
|
* HTTP client for a WordPress site's REST API.
|
|
4
7
|
* Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/snippets` or `wp/v2/plugins`.
|
|
@@ -6,7 +9,7 @@ export declare const REQUEST_TIMEOUT_MS = 30000;
|
|
|
6
9
|
export declare class WpClient {
|
|
7
10
|
private readonly siteUrl;
|
|
8
11
|
private readonly client;
|
|
9
|
-
constructor(siteUrl: string, token: string);
|
|
12
|
+
constructor(siteUrl: string, token: string, environmentName?: string);
|
|
10
13
|
delete<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
|
|
11
14
|
get<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
12
15
|
getAll<T>(path: string, options?: RequestOptions & {
|
package/dist/lib/wp-client.js
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import got from 'got';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
3
|
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
4
|
+
// The one environment allowed to talk plain HTTP: Loopress's local dev environment, identified
|
|
5
|
+
// by its config name (the same "local" environment `lps dev` targets), never by the URL's host.
|
|
6
|
+
// Locality is a property of the configured environment, not of a hostname, so a real remote site
|
|
7
|
+
// can never opt into cleartext just by resolving to a `.local`/loopback-looking name.
|
|
8
|
+
export const LOCAL_ENVIRONMENT_NAME = 'local';
|
|
9
|
+
export function isLocalEnvironment(environmentName) {
|
|
10
|
+
return environmentName === LOCAL_ENVIRONMENT_NAME;
|
|
11
|
+
}
|
|
12
|
+
// Throws when a URL would send credentials in cleartext: http:// for any environment other than
|
|
13
|
+
// the local one. The trust boundary is here, at the client, so a hand-edited config.json or
|
|
14
|
+
// loopress.json cannot bypass the check the interactive prompt also applies.
|
|
15
|
+
export function assertSecureSiteUrl(siteUrl, environmentName) {
|
|
16
|
+
let parsed;
|
|
17
|
+
try {
|
|
18
|
+
parsed = new URL(siteUrl);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error(`Invalid site URL: ${siteUrl}`);
|
|
22
|
+
}
|
|
23
|
+
if (parsed.protocol === 'http:' && !isLocalEnvironment(environmentName)) {
|
|
24
|
+
throw new Error(`Refusing to send credentials over plain HTTP for the "${environmentName ?? 'unknown'}" environment: the Application Password would travel in cleartext. Use HTTPS. Plain HTTP is allowed only for the "${LOCAL_ENVIRONMENT_NAME}" environment.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
4
27
|
/**
|
|
5
28
|
* HTTP client for a WordPress site's REST API.
|
|
6
29
|
* Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/snippets` or `wp/v2/plugins`.
|
|
@@ -8,8 +31,9 @@ export const REQUEST_TIMEOUT_MS = 30_000;
|
|
|
8
31
|
export class WpClient {
|
|
9
32
|
siteUrl;
|
|
10
33
|
client;
|
|
11
|
-
constructor(siteUrl, token) {
|
|
34
|
+
constructor(siteUrl, token, environmentName) {
|
|
12
35
|
this.siteUrl = siteUrl;
|
|
36
|
+
assertSecureSiteUrl(siteUrl, environmentName);
|
|
13
37
|
this.client = got.extend({
|
|
14
38
|
headers: { Authorization: `Basic ${Buffer.from(token).toString('base64')}` },
|
|
15
39
|
prefixUrl: `${siteUrl}/wp-json`,
|
|
@@ -107,6 +131,13 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
107
131
|
const err = error;
|
|
108
132
|
const status = err.response?.statusCode;
|
|
109
133
|
if (status === 401 || status === 403) {
|
|
134
|
+
// A Loopress controller's own `{"error": ...}` on a 403 is a deliberate refusal of this one
|
|
135
|
+
// request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
|
|
136
|
+
// pointing at `lps project config` there sends the user the wrong way. WordPress core's own
|
|
137
|
+
// auth failures use `{code, message}` instead and keep the credentials hint.
|
|
138
|
+
const refusal = extractLoopressError(err.response?.body);
|
|
139
|
+
if (refusal)
|
|
140
|
+
return `Request refused (${status}) on ${url}: ${refusal}`;
|
|
110
141
|
return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
|
|
111
142
|
}
|
|
112
143
|
if (status === 404) {
|
|
@@ -124,6 +155,28 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
124
155
|
}
|
|
125
156
|
return `Request to ${url} failed: ${err.message ?? String(error)}`;
|
|
126
157
|
}
|
|
158
|
+
function extractLoopressError(body) {
|
|
159
|
+
if (!body)
|
|
160
|
+
return undefined;
|
|
161
|
+
try {
|
|
162
|
+
const { error } = JSON.parse(body);
|
|
163
|
+
return typeof error === 'string' && error.trim() ? decodeEntities(error) : undefined;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Plugin exception messages go through esc_html() (WordPress coding standards require it for
|
|
170
|
+
// anything thrown), so quotes and ampersands reach the JSON body as entities. They're meant for
|
|
171
|
+
// a terminal here, not an HTML page. `&` last, so "&quot;" stays a literal """.
|
|
172
|
+
function decodeEntities(text) {
|
|
173
|
+
return text
|
|
174
|
+
.replaceAll('"', '"')
|
|
175
|
+
.replaceAll(''', "'")
|
|
176
|
+
.replaceAll('<', '<')
|
|
177
|
+
.replaceAll('>', '>')
|
|
178
|
+
.replaceAll('&', '&');
|
|
179
|
+
}
|
|
127
180
|
// The Loopress plugin's own controllers reply with `{"error": "..."}`; a WP_Error-based
|
|
128
181
|
// core response (e.g. an uncaught fatal formatted by WordPress itself) uses `{"message": "..."}`.
|
|
129
182
|
// Surfacing this is what makes a deliberately clear server-side error (e.g. "Multiple snippet
|
|
@@ -131,7 +184,7 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
131
184
|
//
|
|
132
185
|
// Some controllers (ComposerController::sync(), notably) pair a short, generic `error` (e.g.
|
|
133
186
|
// "Sync failed.") with the real detail in a separate `output` field (the raw Composer trace),
|
|
134
|
-
// specifically so a caller that only reads `error` doesn't see it
|
|
187
|
+
// specifically so a caller that only reads `error` doesn't see it, which is exactly what this
|
|
135
188
|
// function used to do, hiding the one piece of text that actually explains the failure.
|
|
136
189
|
function extractServerErrorMessage(body) {
|
|
137
190
|
if (!body)
|
|
@@ -145,7 +198,7 @@ function extractServerErrorMessage(body) {
|
|
|
145
198
|
if (parsed.code === 'rest_no_route')
|
|
146
199
|
return undefined;
|
|
147
200
|
const reason = parsed.error ?? parsed.message;
|
|
148
|
-
const summary = typeof reason === 'string' && reason.trim() ? reason : undefined;
|
|
201
|
+
const summary = typeof reason === 'string' && reason.trim() ? decodeEntities(reason) : undefined;
|
|
149
202
|
const detail = typeof parsed.output === 'string' && parsed.output.trim() ? parsed.output.trim() : undefined;
|
|
150
203
|
if (summary && detail)
|
|
151
204
|
return `${summary}\n${detail}`;
|
package/dist/types/plugin.d.ts
CHANGED
|
@@ -12,4 +12,11 @@ export type WpNativePlugin = {
|
|
|
12
12
|
status: 'active' | 'inactive' | 'network-active';
|
|
13
13
|
version: string;
|
|
14
14
|
};
|
|
15
|
-
export type
|
|
15
|
+
export type PluginPin = string | {
|
|
16
|
+
active?: boolean;
|
|
17
|
+
version: string;
|
|
18
|
+
};
|
|
19
|
+
export type PluginManifest = Record<string, PluginPin>;
|
|
20
|
+
export declare const pinVersion: (pin: PluginPin) => string;
|
|
21
|
+
export declare const pinActive: (pin: PluginPin) => boolean;
|
|
22
|
+
export declare const pinVersions: (manifest: PluginManifest) => Record<string, string>;
|
package/dist/types/plugin.js
CHANGED
|
@@ -1 +1,4 @@
|
|
|
1
|
-
export
|
|
1
|
+
export const pinVersion = (pin) => (typeof pin === 'string' ? pin : pin.version);
|
|
2
|
+
export const pinActive = (pin) => typeof pin === 'string' || pin.active !== false;
|
|
3
|
+
// The version-only view the Composer sync endpoint expects.
|
|
4
|
+
export const pinVersions = (manifest) => Object.fromEntries(Object.entries(manifest).map(([slug, pin]) => [slug, pinVersion(pin)]));
|
|
@@ -39,6 +39,14 @@ export interface LoopressProjectConfiguration {
|
|
|
39
39
|
* Directory where static HTML pages (one <slug>.html per WordPress page, managed by Loopress) are read from, relative to rootDir.
|
|
40
40
|
*/
|
|
41
41
|
pageDir?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Directory where block templates (one <slug>.html per template, written to the Loopress child theme of the active block theme) are read from, relative to rootDir. A static page uses one with its `template` header.
|
|
44
|
+
*/
|
|
45
|
+
templateDir?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Directory where block template parts (one <slug>.html per part, written to the Loopress child theme of the active block theme) are read from, relative to rootDir.
|
|
48
|
+
*/
|
|
49
|
+
partDir?: string;
|
|
42
50
|
/**
|
|
43
51
|
* 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.
|
|
44
52
|
*/
|
|
@@ -56,10 +64,19 @@ export interface LoopressProjectConfiguration {
|
|
|
56
64
|
*/
|
|
57
65
|
projectId?: string;
|
|
58
66
|
/**
|
|
59
|
-
* WordPress.org plugins managed by Loopress, installed via Composer + WPackagist on the site. Keys are plugin slugs. A value is either an exact version to pin (e.g. "9.4.2") or "latest" to track the newest release on every push. Removing an entry uninstalls the plugin on the next push. Ignored when a composer.json exists in rootDir (that file becomes authoritative).
|
|
67
|
+
* WordPress.org plugins managed by Loopress, installed via Composer + WPackagist on the site. Keys are plugin slugs. A value is either an exact version to pin (e.g. "9.4.2") or "latest" to track the newest release on every push, and the plugin is kept active. An object with "active": false keeps the plugin installed but inactive. Removing an entry uninstalls the plugin on the next push. Ignored when a composer.json exists in rootDir (that file becomes authoritative).
|
|
60
68
|
*/
|
|
61
69
|
plugins?: {
|
|
62
|
-
[k: string]: string
|
|
70
|
+
[k: string]: string | {
|
|
71
|
+
/**
|
|
72
|
+
* Exact version to pin, or "latest".
|
|
73
|
+
*/
|
|
74
|
+
version: string;
|
|
75
|
+
/**
|
|
76
|
+
* false keeps the plugin installed but inactive. Defaults to true.
|
|
77
|
+
*/
|
|
78
|
+
active?: boolean;
|
|
79
|
+
};
|
|
63
80
|
};
|
|
64
81
|
/**
|
|
65
82
|
* WordPress.org themes managed by Loopress, installed via Composer + WPackagist on the site. Keys are theme slugs; values are an exact version or "latest". Loopress manages installed theme versions only, it never switches the active theme. Ignored when a composer.json exists in rootDir.
|
package/dist/utils/composer.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type SyncIntent } from './plugin-sync.js';
|
|
2
|
+
import { type MergeResult } from './plugins.js';
|
|
1
3
|
export type ComposerJson = {
|
|
2
4
|
config?: {
|
|
3
5
|
'allow-plugins'?: Record<string, boolean>;
|
|
@@ -16,3 +18,15 @@ export type ComposerJson = {
|
|
|
16
18
|
export declare function readComposerJson(): Promise<ComposerJson | null>;
|
|
17
19
|
export declare function readComposerLock(): Promise<null | string>;
|
|
18
20
|
export declare function getComposerManagedSlugs(composerJson: ComposerJson): string[];
|
|
21
|
+
export type ComposerPullResult = MergeResult<string> & {
|
|
22
|
+
skipped: Array<{
|
|
23
|
+
reason: string;
|
|
24
|
+
slug: string;
|
|
25
|
+
}>;
|
|
26
|
+
};
|
|
27
|
+
export declare function pullIntoComposerJson(path: string, kind: 'plugin' | 'theme', incoming: Record<string, string>, { dryRun, log }: {
|
|
28
|
+
dryRun: boolean;
|
|
29
|
+
log: (message: string) => void;
|
|
30
|
+
}): Promise<ComposerPullResult>;
|
|
31
|
+
export declare function toIntent(require: Record<string, string>): SyncIntent;
|
|
32
|
+
export declare function wpackagistRequire(composerJson: ComposerJson, kind: 'plugin' | 'theme'): Record<string, string>;
|