@loopress/cli 0.21.0 → 0.22.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 +169 -56
- package/dist/commands/acf/list.d.ts +2 -2
- package/dist/commands/acf/list.js +2 -5
- package/dist/commands/acf/pull.js +9 -22
- package/dist/commands/acf/push.js +3 -6
- package/dist/commands/api/list.js +2 -1
- package/dist/commands/api/publish.js +6 -22
- package/dist/commands/api/pull.js +12 -17
- package/dist/commands/api/push.js +7 -10
- package/dist/commands/dev.js +1 -1
- package/dist/commands/doctor.js +2 -1
- package/dist/commands/form/list.d.ts +2 -4
- package/dist/commands/form/list.js +3 -10
- package/dist/commands/form/pull.js +15 -24
- package/dist/commands/form/push.js +17 -42
- package/dist/commands/login.js +6 -2
- package/dist/commands/logout.js +2 -1
- package/dist/commands/page/pull.js +17 -28
- package/dist/commands/page/push.js +27 -52
- package/dist/commands/plugin/pull.d.ts +1 -0
- package/dist/commands/plugin/pull.js +8 -10
- package/dist/commands/plugin/push.d.ts +3 -0
- package/dist/commands/plugin/push.js +32 -22
- package/dist/commands/project/config.js +4 -1
- package/dist/commands/project/list.d.ts +16 -1
- package/dist/commands/project/list.js +22 -16
- package/dist/commands/project/pull.js +3 -2
- package/dist/commands/project/push.d.ts +5 -0
- package/dist/commands/project/push.js +79 -58
- package/dist/commands/promote.d.ts +18 -0
- package/dist/commands/promote.js +90 -0
- package/dist/commands/pull.d.ts +12 -0
- package/dist/commands/pull.js +51 -0
- package/dist/commands/push.d.ts +13 -0
- package/dist/commands/push.js +57 -0
- package/dist/commands/seo/list.d.ts +9 -2
- package/dist/commands/seo/list.js +9 -11
- package/dist/commands/seo/pull.js +14 -37
- package/dist/commands/seo/push.js +24 -48
- package/dist/commands/snippet/list.js +2 -1
- package/dist/commands/snippet/publish.js +5 -21
- package/dist/commands/snippet/pull.js +15 -20
- package/dist/commands/snippet/push.js +21 -30
- package/dist/commands/validate.d.ts +8 -0
- package/dist/commands/validate.js +24 -0
- package/dist/lib/base.d.ts +9 -0
- package/dist/lib/base.js +51 -22
- package/dist/lib/guard-production-push.d.ts +12 -0
- package/dist/lib/guard-production-push.js +20 -0
- package/dist/lib/load-snippets.js +65 -60
- package/dist/lib/local-callback-server.js +9 -4
- package/dist/lib/push-command.d.ts +3 -0
- package/dist/lib/push-command.js +22 -15
- package/dist/lib/put-or-create.d.ts +13 -0
- package/dist/lib/put-or-create.js +19 -0
- package/dist/lib/readdir-tolerant.d.ts +5 -0
- package/dist/lib/readdir-tolerant.js +11 -0
- package/dist/lib/resolve-linked-project.d.ts +8 -0
- package/dist/lib/resolve-linked-project.js +27 -0
- package/dist/lib/validate-local.d.ts +10 -0
- package/dist/lib/validate-local.js +183 -0
- package/dist/utils/pluralize.d.ts +1 -0
- package/dist/utils/pluralize.js +5 -0
- package/oclif.manifest.json +242 -27
- package/package.json +16 -15
package/dist/lib/base.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { confirm } from '@inquirer/prompts';
|
|
2
2
|
import { Command, Flags } from '@oclif/core';
|
|
3
|
-
import {
|
|
3
|
+
import { Listr } from 'listr2';
|
|
4
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { configManager } from '../config/project-config.manager.js';
|
|
6
7
|
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
8
|
+
import { pluralize } from '../utils/pluralize.js';
|
|
7
9
|
import { isInteractive } from './interactive.js';
|
|
8
10
|
import { isAppPasswordStale, rotateAppPassword } from './rotate-app-password.js';
|
|
9
11
|
import { WpClient } from './wp-client.js';
|
|
@@ -77,6 +79,31 @@ export class LoopressCommand extends Command {
|
|
|
77
79
|
// swallowed: see comment above
|
|
78
80
|
}
|
|
79
81
|
}
|
|
82
|
+
// Shared shape of every resource's directory pull: write each item, then reconcile local
|
|
83
|
+
// files against the remote list (see removeOrphanedFiles above). `dryRunMessage` and
|
|
84
|
+
// `pulledMessage` are fully-formed by the caller (label pluralization varies per resource,
|
|
85
|
+
// e.g. ACF's `type` is a fixed category name, never pluralized), the "[dry-run] Would "
|
|
86
|
+
// prefix and the orphans notice are the only wording this method owns.
|
|
87
|
+
async pullDirectory(dir, items, orphans, options) {
|
|
88
|
+
if (this.dryRun) {
|
|
89
|
+
this.log(`[dry-run] ${options.dryRunMessage}`);
|
|
90
|
+
if (orphans.length > 0) {
|
|
91
|
+
this.log(`[dry-run] Would remove ${pluralize(orphans.length, 'local file')} ${options.orphanReason}: ${orphans.join(', ')}`);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (options.alwaysCreateDir === true || items.length > 0)
|
|
96
|
+
await mkdir(dir, { recursive: true });
|
|
97
|
+
await new Listr(items.map((item) => ({
|
|
98
|
+
async task(_ctx, task) {
|
|
99
|
+
await options.write(item, dir);
|
|
100
|
+
task.output = `Pulled: ${options.title(item)}`;
|
|
101
|
+
},
|
|
102
|
+
title: `Pull ${options.title(item)}`,
|
|
103
|
+
})), { renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
|
|
104
|
+
await this.removeOrphanedFiles(dir, orphans, options.orphanReason);
|
|
105
|
+
this.log(options.pulledMessage);
|
|
106
|
+
}
|
|
80
107
|
// Orphan cleanup shared by every pull command (see Push Deletion Rules in the product docs):
|
|
81
108
|
// in a TTY the list is shown and confirmed first (Enter accepts), --yes skips the prompt, and
|
|
82
109
|
// outside a TTY the files are removed with a warning, the behavior scripts relied on before
|
|
@@ -84,7 +111,7 @@ export class LoopressCommand extends Command {
|
|
|
84
111
|
async removeOrphanedFiles(dir, orphans, reason) {
|
|
85
112
|
if (orphans.length === 0)
|
|
86
113
|
return;
|
|
87
|
-
const description = `${orphans.length
|
|
114
|
+
const description = `${pluralize(orphans.length, 'local file')} ${reason}: ${orphans.join(', ')}`;
|
|
88
115
|
if (!this.yes && isInteractive()) {
|
|
89
116
|
const isProceed = await confirm({ default: true, message: `Remove ${description}?` });
|
|
90
117
|
if (!isProceed) {
|
|
@@ -143,26 +170,7 @@ export class LoopressCommand extends Command {
|
|
|
143
170
|
}
|
|
144
171
|
resolveEnvironment(envName) {
|
|
145
172
|
if (this.localConfig.projectId) {
|
|
146
|
-
|
|
147
|
-
const project = configManager.getProject(projectId);
|
|
148
|
-
if (!project) {
|
|
149
|
-
this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
|
|
150
|
-
}
|
|
151
|
-
const envNames = Object.keys(project.environments);
|
|
152
|
-
if (envNames.length === 0) {
|
|
153
|
-
this.error(`Project "${project.name}" has no environments configured. Run \`lps project config\` to add one.`);
|
|
154
|
-
}
|
|
155
|
-
if (envName)
|
|
156
|
-
return { env: this.pickEnvironment(project, envName), projectId };
|
|
157
|
-
if (envNames.length === 1) {
|
|
158
|
-
return { env: project.environments[envNames[0]], projectId };
|
|
159
|
-
}
|
|
160
|
-
const current = configManager.getCurrentProject();
|
|
161
|
-
const currentEnv = current?.id === projectId ? configManager.getCurrentEnv() : null;
|
|
162
|
-
if (!currentEnv) {
|
|
163
|
-
this.error(`Project "${project.name}" has multiple environments. Run \`lps project switch\` to pick one.`);
|
|
164
|
-
}
|
|
165
|
-
return { env: currentEnv, projectId };
|
|
173
|
+
return this.resolveEnvironmentForConfiguredProject(this.localConfig.projectId, envName);
|
|
166
174
|
}
|
|
167
175
|
if (envName) {
|
|
168
176
|
const current = configManager.getCurrentProject();
|
|
@@ -177,4 +185,25 @@ export class LoopressCommand extends Command {
|
|
|
177
185
|
return { env, projectId: current.id };
|
|
178
186
|
this.error('No environment configured. Run `lps project config` first.');
|
|
179
187
|
}
|
|
188
|
+
resolveEnvironmentForConfiguredProject(projectId, envName) {
|
|
189
|
+
const project = configManager.getProject(projectId);
|
|
190
|
+
if (!project) {
|
|
191
|
+
this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
|
|
192
|
+
}
|
|
193
|
+
const envNames = Object.keys(project.environments);
|
|
194
|
+
if (envNames.length === 0) {
|
|
195
|
+
this.error(`Project "${project.name}" has no environments configured. Run \`lps project config\` to add one.`);
|
|
196
|
+
}
|
|
197
|
+
if (envName)
|
|
198
|
+
return { env: this.pickEnvironment(project, envName), projectId };
|
|
199
|
+
if (envNames.length === 1) {
|
|
200
|
+
return { env: project.environments[envNames[0]], projectId };
|
|
201
|
+
}
|
|
202
|
+
const current = configManager.getCurrentProject();
|
|
203
|
+
const currentEnv = current?.id === projectId ? configManager.getCurrentEnv() : null;
|
|
204
|
+
if (!currentEnv) {
|
|
205
|
+
this.error(`Project "${project.name}" has multiple environments. Run \`lps project switch\` to pick one.`);
|
|
206
|
+
}
|
|
207
|
+
return { env: currentEnv, projectId };
|
|
208
|
+
}
|
|
180
209
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type GuardProductionPushOptions = {
|
|
2
|
+
dryRun: boolean;
|
|
3
|
+
error: (message: string) => never;
|
|
4
|
+
onRefuse?: () => void;
|
|
5
|
+
siteConfig: {
|
|
6
|
+
name: string;
|
|
7
|
+
url: string;
|
|
8
|
+
};
|
|
9
|
+
yes: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function guardProductionPush(options: GuardProductionPushOptions): Promise<void>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { isInteractive } from './interactive.js';
|
|
3
|
+
// Pushing to an environment named "production" needs explicit intent: a TTY confirmation
|
|
4
|
+
// (Enter accepts), or --yes in scripts and CI. Dry runs are exempt, they change nothing.
|
|
5
|
+
// Shared by PushCommand (lib/push-command.ts, guards a single resource push) and the
|
|
6
|
+
// top-level `lps push` (commands/push.ts, guards once before delegating to 8 sub-commands).
|
|
7
|
+
export async function guardProductionPush(options) {
|
|
8
|
+
const { dryRun, error, onRefuse, siteConfig, yes } = options;
|
|
9
|
+
if (dryRun || yes || siteConfig.name.toLowerCase() !== 'production')
|
|
10
|
+
return;
|
|
11
|
+
if (!isInteractive()) {
|
|
12
|
+
onRefuse?.();
|
|
13
|
+
error('Target environment is "production". Pass --yes to confirm the push in a non-interactive run.');
|
|
14
|
+
}
|
|
15
|
+
const isProceed = await confirm({ default: true, message: `Push to production (${siteConfig.url})?` });
|
|
16
|
+
if (!isProceed) {
|
|
17
|
+
onRefuse?.();
|
|
18
|
+
error('Aborted.');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -8,12 +8,72 @@ const TYPE_BY_EXTENSION = {
|
|
|
8
8
|
'.php': 'php',
|
|
9
9
|
'.txt': 'text',
|
|
10
10
|
};
|
|
11
|
+
// One snippet's files are read in isolation: a corrupted or hand-broken sidecar (bad JSON,
|
|
12
|
+
// unreadable file, ...) must only skip that snippet, not abort loading every other one.
|
|
13
|
+
async function loadSnippet(path, file, onSkip) {
|
|
14
|
+
const ext = extname(file);
|
|
15
|
+
if (!Object.hasOwn(TYPE_BY_EXTENSION, ext))
|
|
16
|
+
return undefined;
|
|
17
|
+
const filePath = join(path, file);
|
|
18
|
+
const metaPath = join(path, `${basename(file, ext)}.json`);
|
|
19
|
+
let content;
|
|
20
|
+
try {
|
|
21
|
+
content = await readFile(filePath, 'utf8');
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
onSkip?.(`Skipping "${filePath}": ${error.message}`);
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
let id;
|
|
28
|
+
let name;
|
|
29
|
+
let type;
|
|
30
|
+
let isActive = false;
|
|
31
|
+
let tags = [];
|
|
32
|
+
let location = null;
|
|
33
|
+
let insertMethod = null;
|
|
34
|
+
let priority = 10;
|
|
35
|
+
let shortcodeAttributes = [];
|
|
36
|
+
try {
|
|
37
|
+
const metaContent = await readFile(metaPath, 'utf8');
|
|
38
|
+
const meta = JSON.parse(metaContent);
|
|
39
|
+
id = meta.id;
|
|
40
|
+
// `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
42
|
+
name = meta.name || undefined;
|
|
43
|
+
type = parseType(meta.type) ?? undefined;
|
|
44
|
+
isActive = Boolean(meta.active);
|
|
45
|
+
tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
46
|
+
location = parseLocation(meta.location);
|
|
47
|
+
insertMethod = parseInsertMethod(meta.insertMethod);
|
|
48
|
+
priority = meta.priority ?? 10;
|
|
49
|
+
shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code !== 'ENOENT') {
|
|
53
|
+
onSkip?.(`Skipping "${metaPath}": ${error.message}`);
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const resolvedType = type ?? (Object.hasOwn(TYPE_BY_EXTENSION, ext) ? TYPE_BY_EXTENSION[ext] : 'php');
|
|
58
|
+
return {
|
|
59
|
+
active: isActive,
|
|
60
|
+
code: content,
|
|
61
|
+
id,
|
|
62
|
+
insertMethod: insertMethod ?? 'auto',
|
|
63
|
+
location: location ?? defaultLocationForType(resolvedType),
|
|
64
|
+
name: name ?? basename(file, ext),
|
|
65
|
+
path: filePath,
|
|
66
|
+
priority,
|
|
67
|
+
shortcodeAttributes,
|
|
68
|
+
tags,
|
|
69
|
+
type: resolvedType,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
11
72
|
// Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
|
|
12
73
|
// (publishes to the Loopress api as a shared source) — both read the same local `snippets/`
|
|
13
74
|
// directory the same way. `onSkip` is optional so callers that don't care about per-file
|
|
14
75
|
// warnings (e.g. `snippet publish`) can ignore them; skipped files are simply left out either way.
|
|
15
76
|
export async function loadSnippets(path, onSkip) {
|
|
16
|
-
const snippets = [];
|
|
17
77
|
let files;
|
|
18
78
|
try {
|
|
19
79
|
files = await readdir(path);
|
|
@@ -21,66 +81,11 @@ export async function loadSnippets(path, onSkip) {
|
|
|
21
81
|
catch (error) {
|
|
22
82
|
throw new Error(`Error loading snippets: ${error.message}`, { cause: error });
|
|
23
83
|
}
|
|
84
|
+
const snippets = [];
|
|
24
85
|
for (const file of files) {
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
const filePath = join(path, file);
|
|
29
|
-
const metaPath = join(path, `${basename(file, ext)}.json`);
|
|
30
|
-
// One snippet's files are read in isolation: a corrupted or hand-broken sidecar (bad JSON,
|
|
31
|
-
// unreadable file, ...) must only skip that snippet, not abort loading every other one.
|
|
32
|
-
let content;
|
|
33
|
-
try {
|
|
34
|
-
content = await readFile(filePath, 'utf8');
|
|
35
|
-
}
|
|
36
|
-
catch (error) {
|
|
37
|
-
onSkip?.(`Skipping "${filePath}": ${error.message}`);
|
|
38
|
-
continue;
|
|
39
|
-
}
|
|
40
|
-
let id;
|
|
41
|
-
let name;
|
|
42
|
-
let type;
|
|
43
|
-
let isActive = false;
|
|
44
|
-
let tags = [];
|
|
45
|
-
let location = null;
|
|
46
|
-
let insertMethod = null;
|
|
47
|
-
let priority = 10;
|
|
48
|
-
let shortcodeAttributes = [];
|
|
49
|
-
try {
|
|
50
|
-
const metaContent = await readFile(metaPath, 'utf8');
|
|
51
|
-
const meta = JSON.parse(metaContent);
|
|
52
|
-
id = meta.id;
|
|
53
|
-
// `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
|
|
54
|
-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
55
|
-
name = meta.name || undefined;
|
|
56
|
-
type = parseType(meta.type) ?? undefined;
|
|
57
|
-
isActive = Boolean(meta.active);
|
|
58
|
-
tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
59
|
-
location = parseLocation(meta.location);
|
|
60
|
-
insertMethod = parseInsertMethod(meta.insertMethod);
|
|
61
|
-
priority = meta.priority ?? 10;
|
|
62
|
-
shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
|
|
63
|
-
}
|
|
64
|
-
catch (error) {
|
|
65
|
-
if (error.code !== 'ENOENT') {
|
|
66
|
-
onSkip?.(`Skipping "${metaPath}": ${error.message}`);
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
const resolvedType = type ?? (Object.hasOwn(TYPE_BY_EXTENSION, ext) ? TYPE_BY_EXTENSION[ext] : 'php');
|
|
71
|
-
snippets.push({
|
|
72
|
-
active: isActive,
|
|
73
|
-
code: content,
|
|
74
|
-
id,
|
|
75
|
-
insertMethod: insertMethod ?? 'auto',
|
|
76
|
-
location: location ?? defaultLocationForType(resolvedType),
|
|
77
|
-
name: name ?? basename(file, ext),
|
|
78
|
-
path: filePath,
|
|
79
|
-
priority,
|
|
80
|
-
shortcodeAttributes,
|
|
81
|
-
tags,
|
|
82
|
-
type: resolvedType,
|
|
83
|
-
});
|
|
86
|
+
const snippet = await loadSnippet(path, file, onSkip);
|
|
87
|
+
if (snippet)
|
|
88
|
+
snippets.push(snippet);
|
|
84
89
|
}
|
|
85
90
|
return snippets;
|
|
86
91
|
}
|
|
@@ -17,20 +17,25 @@ function parseFormData(body) {
|
|
|
17
17
|
export async function waitForLocalCallback(options) {
|
|
18
18
|
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
|
|
19
19
|
return new Promise((resolve, reject) => {
|
|
20
|
-
function finish(res, page
|
|
20
|
+
function finish(res, page) {
|
|
21
21
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
22
22
|
res.end(page);
|
|
23
23
|
clearTimeout(timer);
|
|
24
24
|
server.close();
|
|
25
|
-
settle();
|
|
26
25
|
}
|
|
27
26
|
async function handleIncoming(req, res) {
|
|
28
27
|
try {
|
|
29
28
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
30
29
|
const body = req.method === 'POST' ? parseFormData(await readBody(req)) : {};
|
|
31
30
|
options.handleRequest(url, {
|
|
32
|
-
rejectWithPage(page, error) {
|
|
33
|
-
|
|
31
|
+
rejectWithPage(page, error) {
|
|
32
|
+
finish(res, page);
|
|
33
|
+
reject(error);
|
|
34
|
+
},
|
|
35
|
+
resolveWithPage(page, value) {
|
|
36
|
+
finish(res, page);
|
|
37
|
+
resolve(value);
|
|
38
|
+
},
|
|
34
39
|
respondBadRequest(message) {
|
|
35
40
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
36
41
|
res.end(message);
|
|
@@ -10,4 +10,7 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
10
10
|
protected reportTaskFailure(message: string, error: unknown, task?: {
|
|
11
11
|
output: string;
|
|
12
12
|
}): never;
|
|
13
|
+
protected runPushTasks<T>(items: T[], label: (item: T) => string, task: (item: T, task?: {
|
|
14
|
+
output: string;
|
|
15
|
+
}) => Promise<void>): Promise<void>;
|
|
13
16
|
}
|
package/dist/lib/push-command.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { confirm } from '@inquirer/prompts';
|
|
2
1
|
import got from 'got';
|
|
2
|
+
import { Listr } from 'listr2';
|
|
3
3
|
import { authManager } from '../config/auth.manager.js';
|
|
4
4
|
import { API_URL } from './api-client.js';
|
|
5
5
|
import { LoopressCommand } from './base.js';
|
|
6
|
-
import {
|
|
6
|
+
import { guardProductionPush } from './guard-production-push.js';
|
|
7
7
|
export class PushCommand extends LoopressCommand {
|
|
8
8
|
failedCount = 0;
|
|
9
9
|
// Refusals of the production guard never reach the site, so they must not pollute the
|
|
@@ -15,20 +15,16 @@ export class PushCommand extends LoopressCommand {
|
|
|
15
15
|
}
|
|
16
16
|
return super.catch(err);
|
|
17
17
|
}
|
|
18
|
-
// Pushing to an environment named "production" needs explicit intent: a TTY confirmation
|
|
19
|
-
// (Enter accepts), or --yes in scripts and CI. Dry runs are exempt, they change nothing.
|
|
20
18
|
async guardProductionPush() {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
this.error('Aborted.');
|
|
31
|
-
}
|
|
19
|
+
await guardProductionPush({
|
|
20
|
+
dryRun: this.dryRun,
|
|
21
|
+
error: (message) => this.error(message),
|
|
22
|
+
onRefuse: () => {
|
|
23
|
+
this.refusedByGuard = true;
|
|
24
|
+
},
|
|
25
|
+
siteConfig: this.siteConfig,
|
|
26
|
+
yes: this.yes,
|
|
27
|
+
});
|
|
32
28
|
}
|
|
33
29
|
async init() {
|
|
34
30
|
await super.init();
|
|
@@ -66,4 +62,15 @@ export class PushCommand extends LoopressCommand {
|
|
|
66
62
|
this.failedCount++;
|
|
67
63
|
throw error;
|
|
68
64
|
}
|
|
65
|
+
// Shared shape of every resource's push list: sequential (`concurrent: false` avoids
|
|
66
|
+
// clobbering `failedCount` and interleaving WordPress writes), and a failing item never
|
|
67
|
+
// stops its siblings (`exitOnError: false`) so `failedCount` reflects every failure, not
|
|
68
|
+
// just the first. `label` feeds the "Push <label>" task title; `task` does the actual work
|
|
69
|
+
// and reports its own failure through `reportTaskFailure`.
|
|
70
|
+
async runPushTasks(items, label, task) {
|
|
71
|
+
await new Listr(items.map((item) => ({
|
|
72
|
+
task: async (_ctx, taskCtx) => task(item, taskCtx),
|
|
73
|
+
title: `Push ${label(item)}`,
|
|
74
|
+
})), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
|
|
75
|
+
}
|
|
69
76
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type WpClient } from './wp-client.js';
|
|
2
|
+
type PutOrCreateOptions = {
|
|
3
|
+
id: null | number;
|
|
4
|
+
payload: Record<string, unknown>;
|
|
5
|
+
postEndpoint: string;
|
|
6
|
+
postPayload?: Record<string, unknown>;
|
|
7
|
+
putEndpoint: (id: number) => string;
|
|
8
|
+
};
|
|
9
|
+
export declare function putOrCreate<T>(wp: WpClient, options: PutOrCreateOptions): Promise<{
|
|
10
|
+
body: T;
|
|
11
|
+
created: boolean;
|
|
12
|
+
}>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isNotFoundError } from './wp-client.js';
|
|
2
|
+
// Update-or-create dance shared by every id-based resource (form, page, seo redirect,
|
|
3
|
+
// snippet, unlike ACF which is key-based and only ever POSTs): PUT to the known id, and only
|
|
4
|
+
// fall back to POST when the site doesn't recognize that id (404), e.g. a fresh install or a
|
|
5
|
+
// database reset.
|
|
6
|
+
export async function putOrCreate(wp, options) {
|
|
7
|
+
const { id, payload, postEndpoint, postPayload = payload, putEndpoint } = options;
|
|
8
|
+
if (id !== null) {
|
|
9
|
+
try {
|
|
10
|
+
const body = await wp.put(putEndpoint(id), payload);
|
|
11
|
+
return { body, created: false };
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (!isNotFoundError(error))
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { body: await wp.post(postEndpoint, postPayload), created: true };
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
export async function readdirTolerant(dir, options) {
|
|
3
|
+
try {
|
|
4
|
+
return options?.withFileTypes ? await readdir(dir, { withFileTypes: true }) : await readdir(dir);
|
|
5
|
+
}
|
|
6
|
+
catch (error) {
|
|
7
|
+
if (error.code === 'ENOENT')
|
|
8
|
+
return [];
|
|
9
|
+
throw error;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type LoopressLocalConfig } from '../utils/loopress-config.js';
|
|
2
|
+
type LinkedProject = {
|
|
3
|
+
apiProjectId: string;
|
|
4
|
+
localConfig: LoopressLocalConfig;
|
|
5
|
+
token: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function resolveLinkedProject(fail: (message: string) => never): Promise<LinkedProject>;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { authManager } from '../config/auth.manager.js';
|
|
2
|
+
import { configManager } from '../config/project-config.manager.js';
|
|
3
|
+
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
4
|
+
// Shared preamble for `api publish` and `snippet publish`. Both target the Loopress cloud (never
|
|
5
|
+
// a WordPress site), so neither extends `LoopressCommand`/`PushCommand` and neither needs an
|
|
6
|
+
// environment resolved, but both need a logged-in user and a project already linked to the
|
|
7
|
+
// account (`lps project push`). `fail` is the caller's `this.error`, so its messages keep
|
|
8
|
+
// oclif's formatting and exit code; typed `=> never` so callers still narrow after the guards.
|
|
9
|
+
export async function resolveLinkedProject(fail) {
|
|
10
|
+
const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token;
|
|
11
|
+
if (!token) {
|
|
12
|
+
fail('Not logged in. Run `lps login` first.');
|
|
13
|
+
}
|
|
14
|
+
const localConfig = await readLocalConfig();
|
|
15
|
+
const projectId = localConfig.projectId ?? configManager.getCurrentProject()?.id;
|
|
16
|
+
if (!projectId) {
|
|
17
|
+
fail('No project configured. Run `lps project config` first.');
|
|
18
|
+
}
|
|
19
|
+
const project = configManager.getProject(projectId);
|
|
20
|
+
if (!project) {
|
|
21
|
+
fail(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
|
|
22
|
+
}
|
|
23
|
+
if (!project.apiProjectId) {
|
|
24
|
+
fail(`Project "${project.name}" is not linked to your Loopress account yet. Run \`lps project push\` first.`);
|
|
25
|
+
}
|
|
26
|
+
return { apiProjectId: project.apiProjectId, localConfig, token };
|
|
27
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { configManager } from '../config/project-config.manager.js';
|
|
4
|
+
import { parseType } from '../utils/snippet-format.js';
|
|
5
|
+
import { loadSnippets } from './load-snippets.js';
|
|
6
|
+
import { readdirTolerant } from './readdir-tolerant.js';
|
|
7
|
+
// Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
|
|
8
|
+
// and api routes (`.php`, not JSON) are checked separately.
|
|
9
|
+
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'pageDir', 'seoDir'];
|
|
10
|
+
const DEFAULT_DIR = {
|
|
11
|
+
acfDir: 'acf',
|
|
12
|
+
apiDir: 'api',
|
|
13
|
+
formDir: 'forms',
|
|
14
|
+
pageDir: 'pages',
|
|
15
|
+
seoDir: 'seo',
|
|
16
|
+
snippetsDir: 'snippets',
|
|
17
|
+
};
|
|
18
|
+
// Inspects the local tracked files under `cwd` without contacting WordPress: JSON files parse
|
|
19
|
+
// and have the right shape, snippet sidecars use a known type, `loopress.json` points at a
|
|
20
|
+
// real project, `composer.json` is well formed. Never throws for a bad file, it collects the
|
|
21
|
+
// problem and moves on.
|
|
22
|
+
export async function validateLocal(cwd) {
|
|
23
|
+
const problems = [];
|
|
24
|
+
let checked = 0;
|
|
25
|
+
const localConfig = await readConfig(cwd, problems);
|
|
26
|
+
const rootDir = typeof localConfig.rootDir === 'string' ? localConfig.rootDir : '.';
|
|
27
|
+
const resolve = (key) => {
|
|
28
|
+
const override = localConfig[key];
|
|
29
|
+
return join(cwd, rootDir, typeof override === 'string' ? override : DEFAULT_DIR[key]);
|
|
30
|
+
};
|
|
31
|
+
checkLoopressJson(localConfig, problems);
|
|
32
|
+
checked += 1;
|
|
33
|
+
checked += await checkComposerJson(cwd, problems);
|
|
34
|
+
for (const key of JSON_RESOURCE_DIRS) {
|
|
35
|
+
checked += await checkJsonDir(resolve(key), problems);
|
|
36
|
+
}
|
|
37
|
+
checked += await checkSnippets(resolve('snippetsDir'), problems);
|
|
38
|
+
checked += await checkApiDir(resolve('apiDir'), problems);
|
|
39
|
+
return { checked, problems, valid: problems.length === 0 };
|
|
40
|
+
}
|
|
41
|
+
async function checkApiDir(dir, problems) {
|
|
42
|
+
const entries = await readdirTolerant(dir, { withFileTypes: true });
|
|
43
|
+
let checked = 0;
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (!entry.isFile() || !entry.name.endsWith('.php'))
|
|
46
|
+
continue;
|
|
47
|
+
checked += 1;
|
|
48
|
+
const filePath = join(dir, entry.name);
|
|
49
|
+
const content = await readFile(filePath, 'utf8');
|
|
50
|
+
if (content.trim() === '')
|
|
51
|
+
problems.push({ file: filePath, message: 'API route file is empty' });
|
|
52
|
+
}
|
|
53
|
+
return checked;
|
|
54
|
+
}
|
|
55
|
+
async function checkComposerJson(cwd, problems) {
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = await readFile(join(cwd, 'composer.json'), 'utf8');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// No composer.json is fine, Composer sync is optional.
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(raw);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
problems.push({ file: 'composer.json', message: `not valid JSON: ${error.message}` });
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
for (const key of ['require', 'require-dev']) {
|
|
73
|
+
const section = parsed[key];
|
|
74
|
+
if (section !== undefined && (typeof section !== 'object' || section === null || Array.isArray(section))) {
|
|
75
|
+
problems.push({ file: 'composer.json', message: `"${key}" must be an object` });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
async function checkJsonDir(dir, problems) {
|
|
81
|
+
const files = (await readdirTolerant(dir)).filter((file) => file.endsWith('.json'));
|
|
82
|
+
let checked = 0;
|
|
83
|
+
for (const file of files) {
|
|
84
|
+
checked += 1;
|
|
85
|
+
const filePath = join(dir, file);
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
problems.push({ file: filePath, message: `not valid JSON: ${error.message}` });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
95
|
+
problems.push({ file: filePath, message: 'expected a JSON object' });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return checked;
|
|
99
|
+
}
|
|
100
|
+
function checkLoopressJson(localConfig, problems) {
|
|
101
|
+
const { projectId } = localConfig;
|
|
102
|
+
if (typeof projectId === 'string') {
|
|
103
|
+
const project = configManager.getProject(projectId);
|
|
104
|
+
if (!project) {
|
|
105
|
+
problems.push({
|
|
106
|
+
file: 'loopress.json',
|
|
107
|
+
message: `projectId "${projectId}" is not a configured project (run \`lps project config\`)`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
else if (Object.keys(project.environments).length === 0) {
|
|
111
|
+
problems.push({ file: 'loopress.json', message: `project "${project.name}" has no environments configured` });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const { plugins } = localConfig;
|
|
115
|
+
if (plugins === undefined)
|
|
116
|
+
return;
|
|
117
|
+
if (typeof plugins !== 'object' || plugins === null || Array.isArray(plugins)) {
|
|
118
|
+
problems.push({ file: 'loopress.json', message: '"plugins" must be an object of slug -> version string' });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
for (const [slug, version] of Object.entries(plugins)) {
|
|
122
|
+
if (typeof version !== 'string')
|
|
123
|
+
problems.push({ file: 'loopress.json', message: `plugin "${slug}" must map to a string` });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function checkSnippets(dir, problems) {
|
|
127
|
+
const listing = await readdirTolerant(dir);
|
|
128
|
+
if (listing.length === 0)
|
|
129
|
+
return 0;
|
|
130
|
+
// loadSnippets reports unreadable files and unparseable sidecars through onSkip.
|
|
131
|
+
let snippets;
|
|
132
|
+
try {
|
|
133
|
+
snippets = await loadSnippets(dir, (message) => {
|
|
134
|
+
problems.push({ file: dir, message });
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
problems.push({ file: dir, message: error.message });
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
// loadSnippets coerces an invalid `type` to the extension default rather than failing, so
|
|
142
|
+
// check the raw sidecars for a bogus enum value the schema would reject.
|
|
143
|
+
const sidecars = listing.filter((file) => file.endsWith('.json'));
|
|
144
|
+
for (const file of sidecars) {
|
|
145
|
+
const filePath = join(dir, file);
|
|
146
|
+
let meta;
|
|
147
|
+
try {
|
|
148
|
+
meta = JSON.parse(await readFile(filePath, 'utf8'));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
problems.push({ file: filePath, message: 'not valid JSON' });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (meta.type !== undefined && parseType(meta.type) === null) {
|
|
155
|
+
problems.push({ file: filePath, message: `"type": ${JSON.stringify(meta.type)} is not one of css, html, js, php, text` });
|
|
156
|
+
}
|
|
157
|
+
if (meta.id !== undefined && !Number.isSafeInteger(meta.id)) {
|
|
158
|
+
problems.push({ file: filePath, message: '"id" must be an integer' });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return snippets.length + sidecars.length;
|
|
162
|
+
}
|
|
163
|
+
// Mirrors utils/loopress-config.ts:readLocalConfig, but rooted at an explicit directory rather
|
|
164
|
+
// than process.cwd() so the whole check is driveable from a test without process.chdir(), which
|
|
165
|
+
// vitest's worker threads forbid. A missing file just means "no config".
|
|
166
|
+
async function readConfig(cwd, problems) {
|
|
167
|
+
let raw;
|
|
168
|
+
try {
|
|
169
|
+
raw = await readFile(join(cwd, 'loopress.json'), 'utf8');
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error.code === 'ENOENT')
|
|
173
|
+
return {};
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
return JSON.parse(raw);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
problems.push({ file: 'loopress.json', message: 'loopress.json is not valid JSON. Fix or delete it, then run `lps init` again.' });
|
|
181
|
+
return {};
|
|
182
|
+
}
|
|
183
|
+
}
|