@loopress/cli 0.29.0 → 0.30.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 +1301 -345
- package/dist/commands/cpt/diff.js +5 -0
- package/dist/commands/cpt/list.d.ts +2 -0
- package/dist/commands/cpt/list.js +4 -0
- package/dist/commands/cpt/pull.d.ts +2 -0
- package/dist/commands/cpt/pull.js +4 -0
- package/dist/commands/cpt/push.d.ts +2 -0
- package/dist/commands/cpt/push.js +4 -0
- package/dist/commands/cpt/rm.d.ts +2 -0
- package/dist/commands/cpt/rm.js +3 -0
- package/dist/commands/cpt/rollback.js +5 -0
- package/dist/commands/dev.js +14 -4
- package/dist/commands/diff.js +1 -1
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +30 -2
- package/dist/commands/meta/diff.d.ts +2 -0
- package/dist/commands/meta/diff.js +5 -0
- package/dist/commands/meta/list.d.ts +19 -0
- package/dist/commands/meta/list.js +21 -0
- package/dist/commands/meta/pull.d.ts +2 -0
- package/dist/commands/meta/pull.js +4 -0
- package/dist/commands/meta/push.d.ts +2 -0
- package/dist/commands/meta/push.js +4 -0
- package/dist/commands/meta/rm.d.ts +2 -0
- package/dist/commands/meta/rm.js +3 -0
- package/dist/commands/meta/rollback.d.ts +2 -0
- package/dist/commands/meta/rollback.js +5 -0
- package/dist/commands/option/list.js +2 -8
- package/dist/commands/pattern/diff.d.ts +2 -0
- package/dist/commands/pattern/diff.js +5 -0
- package/dist/commands/pattern/list.d.ts +2 -0
- package/dist/commands/pattern/list.js +3 -0
- package/dist/commands/pattern/pull.d.ts +2 -0
- package/dist/commands/pattern/pull.js +4 -0
- package/dist/commands/pattern/push.d.ts +2 -0
- package/dist/commands/pattern/push.js +4 -0
- package/dist/commands/pattern/rm.d.ts +2 -0
- package/dist/commands/pattern/rm.js +3 -0
- package/dist/commands/pattern/rollback.d.ts +2 -0
- package/dist/commands/pattern/rollback.js +5 -0
- package/dist/commands/plugin/audit.js +2 -1
- package/dist/commands/project/config.d.ts +1 -0
- package/dist/commands/project/config.js +20 -1
- package/dist/commands/project/list.d.ts +1 -1
- package/dist/commands/project/list.js +6 -6
- package/dist/commands/project/pull.js +11 -8
- package/dist/commands/project/push.js +23 -18
- package/dist/commands/project/remove.js +1 -1
- package/dist/commands/project/switch.d.ts +0 -1
- package/dist/commands/project/switch.js +16 -36
- package/dist/commands/pull.js +7 -2
- package/dist/commands/push.js +9 -2
- package/dist/commands/role/diff.d.ts +2 -0
- package/dist/commands/role/diff.js +5 -0
- package/dist/commands/role/list.d.ts +2 -0
- package/dist/commands/role/list.js +4 -0
- package/dist/commands/role/pull.d.ts +2 -0
- package/dist/commands/role/pull.js +4 -0
- package/dist/commands/role/push.d.ts +2 -0
- package/dist/commands/role/push.js +4 -0
- package/dist/commands/role/rm.d.ts +2 -0
- package/dist/commands/role/rm.js +3 -0
- package/dist/commands/role/rollback.d.ts +2 -0
- package/dist/commands/role/rollback.js +5 -0
- package/dist/commands/status.d.ts +2 -3
- package/dist/commands/status.js +21 -53
- package/dist/commands/taxonomy/diff.d.ts +2 -0
- package/dist/commands/taxonomy/diff.js +5 -0
- package/dist/commands/taxonomy/list.d.ts +2 -0
- package/dist/commands/taxonomy/list.js +5 -0
- package/dist/commands/taxonomy/pull.d.ts +2 -0
- package/dist/commands/taxonomy/pull.js +4 -0
- package/dist/commands/taxonomy/push.d.ts +2 -0
- package/dist/commands/taxonomy/push.js +4 -0
- package/dist/commands/taxonomy/rm.d.ts +2 -0
- package/dist/commands/taxonomy/rm.js +3 -0
- package/dist/commands/taxonomy/rollback.d.ts +2 -0
- package/dist/commands/taxonomy/rollback.js +5 -0
- package/dist/commands/theme/template/push.d.ts +2 -0
- package/dist/commands/theme/template/push.js +26 -5
- package/dist/config/project-config.manager.d.ts +3 -3
- package/dist/config/project-config.manager.js +22 -31
- package/dist/hooks/finally.js +11 -3
- package/dist/lib/api-client.js +2 -1
- package/dist/lib/app-build.js +3 -1
- package/dist/lib/base.d.ts +1 -2
- package/dist/lib/base.js +30 -38
- package/dist/lib/declared-type-command.d.ts +35 -0
- package/dist/lib/declared-type-command.js +192 -0
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +7 -3
- package/dist/lib/github-release.js +4 -0
- package/dist/lib/local-callback-server.js +3 -1
- package/dist/lib/push-command.d.ts +1 -0
- package/dist/lib/push-command.js +31 -10
- package/dist/lib/put-or-create.js +3 -4
- package/dist/lib/resource-state.js +33 -80
- package/dist/lib/rollback-materialize.js +13 -24
- package/dist/lib/spinner.d.ts +2 -0
- package/dist/lib/spinner.js +72 -0
- package/dist/lib/temp-admin.d.ts +10 -1
- package/dist/lib/temp-admin.js +27 -2
- package/dist/lib/validate-local.js +31 -9
- package/dist/lib/wp-client.d.ts +1 -0
- package/dist/lib/wp-client.js +7 -1
- package/dist/lib/wp-site-diagnostic.js +3 -2
- package/dist/types/global-config.generated.d.ts +1 -5
- package/dist/types/project-config.generated.d.ts +28 -4
- package/dist/utils/composer.js +2 -1
- package/dist/utils/declared-type-format.d.ts +52 -0
- package/dist/utils/declared-type-format.js +233 -0
- package/dist/utils/resource-dirs.d.ts +9 -3
- package/dist/utils/resource-dirs.js +16 -4
- package/dist/utils/template-format.d.ts +1 -0
- package/dist/utils/theme-css.d.ts +19 -0
- package/dist/utils/theme-css.js +100 -0
- package/oclif.manifest.json +3012 -1133
- package/package.json +28 -15
- package/schemas/global-config.schema.json +2 -6
- package/schemas/project-config.schema.json +37 -7
- package/dist/commands/seo/diff.js +0 -5
- package/dist/commands/seo/list.d.ts +0 -18
- package/dist/commands/seo/list.js +0 -58
- package/dist/commands/seo/pull.d.ts +0 -24
- package/dist/commands/seo/pull.js +0 -89
- package/dist/commands/seo/push.d.ts +0 -29
- package/dist/commands/seo/push.js +0 -178
- package/dist/commands/seo/rollback.js +0 -5
- package/dist/utils/seo-format.d.ts +0 -29
- package/dist/utils/seo-format.js +0 -18
- /package/dist/commands/{seo → cpt}/diff.d.ts +0 -0
- /package/dist/commands/{seo → cpt}/rollback.d.ts +0 -0
package/dist/hooks/finally.js
CHANGED
|
@@ -27,10 +27,18 @@ async function report(context, options) {
|
|
|
27
27
|
dsn: SENTRY_DSN,
|
|
28
28
|
environment: resolveEnvironment(),
|
|
29
29
|
release: context.config.version,
|
|
30
|
+
// Sentry 11 collects all of these by default. Headers carry the app password, stack frame
|
|
31
|
+
// locals can hold it too, and this reports from users' own machines: collect none of it.
|
|
32
|
+
dataCollection: {
|
|
33
|
+
cookies: false,
|
|
34
|
+
httpBodies: [],
|
|
35
|
+
httpHeaders: false,
|
|
36
|
+
stackFrameVariables: false,
|
|
37
|
+
urlQueryParams: false,
|
|
38
|
+
userInfo: false,
|
|
39
|
+
},
|
|
30
40
|
// Node defaults `server_name` to os.hostname(), which is often the user's real name
|
|
31
|
-
// (e.g. "jane-doe-macbook-pro").
|
|
32
|
-
// default but set explicitly since this reports from users' own machines.
|
|
33
|
-
sendDefaultPii: false,
|
|
41
|
+
// (e.g. "jane-doe-macbook-pro").
|
|
34
42
|
serverName: 'loopress',
|
|
35
43
|
});
|
|
36
44
|
Sentry.captureException(options.error, {
|
package/dist/lib/api-client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import got from 'got';
|
|
2
|
+
import { withSpinner } from './spinner.js';
|
|
2
3
|
import { REQUEST_TIMEOUT_MS } from './wp-client.js';
|
|
3
4
|
export const API_URL = process.env.LPS_API_URL ?? 'https://api.loopress.dev';
|
|
4
5
|
/**
|
|
@@ -29,7 +30,7 @@ export class ApiClient {
|
|
|
29
30
|
}
|
|
30
31
|
async request(method, path, json) {
|
|
31
32
|
try {
|
|
32
|
-
const response = await this.client(path, { json, method });
|
|
33
|
+
const response = await withSpinner('Waiting for the Loopress API', async () => this.client(path, { json, method }));
|
|
33
34
|
return (response.body ? JSON.parse(response.body) : undefined);
|
|
34
35
|
}
|
|
35
36
|
catch (error) {
|
package/dist/lib/app-build.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
|
+
import { withSpinner } from './spinner.js';
|
|
5
6
|
// The package managers `lps app push` may spawn. An allowlist, not the raw `packageManager`
|
|
6
7
|
// field: the name ends up as a process to run.
|
|
7
8
|
const PACKAGE_MANAGERS = ['bun', 'npm', 'pnpm', 'yarn'];
|
|
@@ -43,7 +44,7 @@ export async function buildApp(appDir, onStart) {
|
|
|
43
44
|
const pm = detectPackageManager(appDir, pkg.packageManager);
|
|
44
45
|
const command = `${pm} run build`;
|
|
45
46
|
onStart?.(command);
|
|
46
|
-
const
|
|
47
|
+
const build = new Promise((resolve, reject) => {
|
|
47
48
|
// Windows resolves `npm` to `npm.cmd`, which only a shell finds. `pm` comes from the
|
|
48
49
|
// allowlist above, so the shell never sees user input.
|
|
49
50
|
const child = spawn(pm, ['run', 'build'], { cwd: appDir, shell: process.platform === 'win32' });
|
|
@@ -58,6 +59,7 @@ export async function buildApp(appDir, onStart) {
|
|
|
58
59
|
resolve({ code: exitCode, output: out });
|
|
59
60
|
});
|
|
60
61
|
});
|
|
62
|
+
const { code, output } = await withSpinner(`Running ${command}`, async () => build);
|
|
61
63
|
if (code !== 0) {
|
|
62
64
|
const tail = output.trimEnd().split('\n').slice(-OUTPUT_TAIL_LINES).join('\n');
|
|
63
65
|
const hint = existsSync(join(appDir, 'node_modules'))
|
package/dist/lib/base.d.ts
CHANGED
|
@@ -39,10 +39,9 @@ export declare abstract class LoopressCommand extends Command {
|
|
|
39
39
|
protected resolveHooksPath(override?: string): string;
|
|
40
40
|
protected resolveMenuPath(override?: string): string;
|
|
41
41
|
protected resolveOptionsPath(override?: string): string;
|
|
42
|
-
protected resolveSeoPath(override?: string): string;
|
|
43
42
|
protected resolveSnippetsPath(override?: string): string;
|
|
44
43
|
protected resolveThemeStylesPath(override?: string): string;
|
|
44
|
+
protected warnRemovedSeoModule(): void;
|
|
45
45
|
private pickEnvironment;
|
|
46
46
|
private resolveEnvironment;
|
|
47
|
-
private resolveEnvironmentForConfiguredProject;
|
|
48
47
|
}
|
package/dist/lib/base.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { confirm } from '@inquirer/prompts';
|
|
2
2
|
import { Command, Flags } from '@oclif/core';
|
|
3
3
|
import { Listr } from 'listr2';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
4
5
|
import { mkdir, rm } from 'node:fs/promises';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
import { configManager } from '../config/project-config.manager.js';
|
|
@@ -9,13 +10,14 @@ import { pluralize } from '../utils/pluralize.js';
|
|
|
9
10
|
import { resolveResourceDir } from '../utils/resource-dirs.js';
|
|
10
11
|
import { isInteractive } from './interactive.js';
|
|
11
12
|
import { isAppPasswordStale, rotateAppPassword } from './rotate-app-password.js';
|
|
13
|
+
import { withoutSpinner } from './spinner.js';
|
|
12
14
|
import { WpClient } from './wp-client.js';
|
|
13
15
|
export class LoopressCommand extends Command {
|
|
14
|
-
// On every subclass without opt-in
|
|
15
|
-
//
|
|
16
|
+
// On every subclass without opt-in. Without it, commands target the "local" environment, so
|
|
17
|
+
// reaching a remote site is always spelled out in the command itself.
|
|
16
18
|
static baseFlags = {
|
|
17
19
|
env: Flags.string({
|
|
18
|
-
description: 'Target environment by name,
|
|
20
|
+
description: 'Target environment by name (default: "local", or the project\'s only environment)',
|
|
19
21
|
}),
|
|
20
22
|
};
|
|
21
23
|
static dryRunFlag = {
|
|
@@ -107,13 +109,15 @@ export class LoopressCommand extends Command {
|
|
|
107
109
|
}
|
|
108
110
|
if (options.alwaysCreateDir === true || items.length > 0)
|
|
109
111
|
await mkdir(dir, { recursive: true });
|
|
110
|
-
await
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
await withoutSpinner(async () => {
|
|
113
|
+
await new Listr(items.map((item) => ({
|
|
114
|
+
async task(_ctx, task) {
|
|
115
|
+
await options.write(item, dir);
|
|
116
|
+
task.output = `Pulled: ${options.title(item)}`;
|
|
117
|
+
},
|
|
118
|
+
title: `Pull ${options.title(item)}`,
|
|
119
|
+
})), { renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
|
|
120
|
+
});
|
|
117
121
|
await this.removeOrphanedFiles(dir, orphans, options.orphanReason);
|
|
118
122
|
this.log(options.pulledMessage);
|
|
119
123
|
}
|
|
@@ -165,15 +169,19 @@ export class LoopressCommand extends Command {
|
|
|
165
169
|
resolveOptionsPath(override) {
|
|
166
170
|
return resolveResourceDir('options', this.localConfig, override);
|
|
167
171
|
}
|
|
168
|
-
resolveSeoPath(override) {
|
|
169
|
-
return resolveResourceDir('seo', this.localConfig, override);
|
|
170
|
-
}
|
|
171
172
|
resolveSnippetsPath(override) {
|
|
172
173
|
return resolveResourceDir('snippets', this.localConfig, override);
|
|
173
174
|
}
|
|
174
175
|
resolveThemeStylesPath(override) {
|
|
175
176
|
return resolveResourceDir('themeStyles', this.localConfig, override);
|
|
176
177
|
}
|
|
178
|
+
// `lps seo` was removed: a leftover seo/ folder or seoDir key would otherwise be silently
|
|
179
|
+
// ignored by `lps pull` / `lps push`. Remove once projects have had a release to migrate.
|
|
180
|
+
warnRemovedSeoModule() {
|
|
181
|
+
if (!('seoDir' in this.localConfig) && !existsSync(join(this.rootDir, 'seo')))
|
|
182
|
+
return;
|
|
183
|
+
this.warn('The SEO module was removed: seo/ is no longer pulled or pushed. Track SEO plugin settings with `lps option add rank-math-options-titles` (or `wpseo_titles`), then delete seo/ and any seoDir key from loopress.json.');
|
|
184
|
+
}
|
|
177
185
|
pickEnvironment(project, envName) {
|
|
178
186
|
const env = project.environments[envName];
|
|
179
187
|
if (!env) {
|
|
@@ -181,24 +189,12 @@ export class LoopressCommand extends Command {
|
|
|
181
189
|
}
|
|
182
190
|
return env;
|
|
183
191
|
}
|
|
192
|
+
// Order: --env, then the project's default (see getDefaultEnvironment). The project comes from
|
|
193
|
+
// loopress.json when pinned, from `lps project switch` otherwise.
|
|
184
194
|
resolveEnvironment(envName) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (envName) {
|
|
189
|
-
const current = configManager.getCurrentProject();
|
|
190
|
-
if (!current) {
|
|
191
|
-
this.error('No project configured. Run `lps project config` first.');
|
|
192
|
-
}
|
|
193
|
-
return { env: this.pickEnvironment(current, envName), projectId: current.id };
|
|
194
|
-
}
|
|
195
|
-
const env = configManager.getCurrentEnv();
|
|
196
|
-
const current = configManager.getCurrentProject();
|
|
197
|
-
if (env && current)
|
|
198
|
-
return { env, projectId: current.id };
|
|
199
|
-
this.error('No environment configured. Run `lps project config` first.');
|
|
200
|
-
}
|
|
201
|
-
resolveEnvironmentForConfiguredProject(projectId, envName) {
|
|
195
|
+
const projectId = this.localConfig.projectId ?? configManager.getCurrentProject()?.id;
|
|
196
|
+
if (!projectId)
|
|
197
|
+
this.error('No project configured. Run `lps project config` first.');
|
|
202
198
|
const project = configManager.getProject(projectId);
|
|
203
199
|
if (!project) {
|
|
204
200
|
this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
|
|
@@ -209,14 +205,10 @@ export class LoopressCommand extends Command {
|
|
|
209
205
|
}
|
|
210
206
|
if (envName)
|
|
211
207
|
return { env: this.pickEnvironment(project, envName), projectId };
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const current = configManager.getCurrentProject();
|
|
216
|
-
const currentEnv = current?.id === projectId ? configManager.getCurrentEnv() : null;
|
|
217
|
-
if (!currentEnv) {
|
|
218
|
-
this.error(`Project "${project.name}" has multiple environments. Run \`lps project switch\` to pick one.`);
|
|
208
|
+
const env = configManager.getDefaultEnvironment(projectId);
|
|
209
|
+
if (!env) {
|
|
210
|
+
this.error(`Project "${project.name}" has no "local" environment, pass --env to pick one. Available: ${envNames.join(', ')}`);
|
|
219
211
|
}
|
|
220
|
-
return { env
|
|
212
|
+
return { env, projectId };
|
|
221
213
|
}
|
|
222
214
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type DeclaredTypeSpec, type RegisteredItem } from '../utils/declared-type-format.js';
|
|
2
|
+
import { LoopressCommand } from './base.js';
|
|
3
|
+
import { type CommandClass } from './php-files-command.js';
|
|
4
|
+
import { PushCommand } from './push-command.js';
|
|
5
|
+
type PushResult = {
|
|
6
|
+
path: string;
|
|
7
|
+
pushed: string[];
|
|
8
|
+
status: 'dry-run' | 'success';
|
|
9
|
+
};
|
|
10
|
+
type PullResult = {
|
|
11
|
+
path: string;
|
|
12
|
+
status: 'dry-run' | 'success';
|
|
13
|
+
};
|
|
14
|
+
type RmResult = {
|
|
15
|
+
removed: boolean;
|
|
16
|
+
slug: string;
|
|
17
|
+
status: 'aborted' | 'dry-run' | 'success';
|
|
18
|
+
};
|
|
19
|
+
export type DeclaredPushCommand = Omit<PushCommand, 'run'> & {
|
|
20
|
+
run(): Promise<PushResult>;
|
|
21
|
+
};
|
|
22
|
+
export declare function declaredPushCommand(spec: DeclaredTypeSpec, description: string): CommandClass<DeclaredPushCommand>;
|
|
23
|
+
export type DeclaredPullCommand = Omit<LoopressCommand, 'run'> & {
|
|
24
|
+
run(): Promise<PullResult>;
|
|
25
|
+
};
|
|
26
|
+
export declare function declaredPullCommand(spec: DeclaredTypeSpec, description: string): CommandClass<DeclaredPullCommand>;
|
|
27
|
+
export type DeclaredListCommand = Omit<LoopressCommand, 'run'> & {
|
|
28
|
+
run(): Promise<RegisteredItem[]>;
|
|
29
|
+
};
|
|
30
|
+
export declare function declaredListCommand(spec: DeclaredTypeSpec, description: string, countLabel: (count: number) => string): CommandClass<DeclaredListCommand>;
|
|
31
|
+
export type DeclaredRmCommand = Omit<LoopressCommand, 'run'> & {
|
|
32
|
+
run(): Promise<RmResult>;
|
|
33
|
+
};
|
|
34
|
+
export declare function declaredRmCommand(spec: DeclaredTypeSpec, description: string, keptNote: string): CommandClass<DeclaredRmCommand>;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { Args } from '@oclif/core';
|
|
3
|
+
import { writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { fileExtension, formatDeclaredFile, itemEndpoint, parseDeclaredFile, } from '../utils/declared-type-format.js';
|
|
6
|
+
import { pluralize } from '../utils/pluralize.js';
|
|
7
|
+
import { resolveResourceDir } from '../utils/resource-dirs.js';
|
|
8
|
+
import { LoopressCommand } from './base.js';
|
|
9
|
+
import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
|
|
10
|
+
import { isInteractive } from './interactive.js';
|
|
11
|
+
import { loadFiles } from './load-files.js';
|
|
12
|
+
import { PushCommand } from './push-command.js';
|
|
13
|
+
import { getResourceStateProvider } from './resource-state.js';
|
|
14
|
+
import { isApplicative404, isNotFoundError } from './wp-client.js';
|
|
15
|
+
const SOURCE_LABELS = {
|
|
16
|
+
acf: 'ACF',
|
|
17
|
+
cptui: 'CPT UI',
|
|
18
|
+
loopress: 'Loopress',
|
|
19
|
+
other: 'theme or plugin',
|
|
20
|
+
wordpress: 'WordPress',
|
|
21
|
+
};
|
|
22
|
+
function pathArg(spec) {
|
|
23
|
+
return Args.string({ description: `Path to the ${spec.dir} directory (overrides project config)` });
|
|
24
|
+
}
|
|
25
|
+
export function declaredPushCommand(spec, description) {
|
|
26
|
+
class DeclaredPush extends PushCommand {
|
|
27
|
+
static args = { path: pathArg(spec) };
|
|
28
|
+
static description = description;
|
|
29
|
+
static enableJsonFlag = true;
|
|
30
|
+
static examples = [`$ lps ${spec.cliName} push`];
|
|
31
|
+
static flags = { ...PushCommand.dryRunFlag, ...PushCommand.yesFlag };
|
|
32
|
+
async run() {
|
|
33
|
+
const { args } = await this.parse(DeclaredPush);
|
|
34
|
+
const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
|
|
35
|
+
this.log(`Pushing ${spec.plural} to ${this.siteConfig.url}`);
|
|
36
|
+
this.log(`Path: ${path}`);
|
|
37
|
+
const provider = getResourceStateProvider(spec.cliName);
|
|
38
|
+
const beforeState = await this.captureBeforePushState(provider, path);
|
|
39
|
+
const items = await loadFiles(path, {
|
|
40
|
+
extension: fileExtension(spec),
|
|
41
|
+
onSkip: (message) => {
|
|
42
|
+
this.warn(message);
|
|
43
|
+
},
|
|
44
|
+
parse: (raw, filePath) => parseDeclaredFile(spec, filePath, raw),
|
|
45
|
+
});
|
|
46
|
+
this.log(`Found ${pluralize(items.length, spec.noun, spec.plural)} to push`);
|
|
47
|
+
const pushed = [];
|
|
48
|
+
await this.runPushTasks(items, (item) => item.slug, async (item, task) => {
|
|
49
|
+
await this.pushItem(item, task);
|
|
50
|
+
pushed.push(item.slug);
|
|
51
|
+
});
|
|
52
|
+
await this.writeAfterPushSnapshot(provider, path, beforeState);
|
|
53
|
+
if (this.failedCount > 0) {
|
|
54
|
+
this.error(`${pluralize(this.failedCount, spec.noun, spec.plural)} failed to push.`);
|
|
55
|
+
}
|
|
56
|
+
if (this.dryRun)
|
|
57
|
+
return { path, pushed, status: 'dry-run' };
|
|
58
|
+
await this.recordSuccess();
|
|
59
|
+
this.log(`All ${spec.plural} pushed.`);
|
|
60
|
+
return { path, pushed, status: 'success' };
|
|
61
|
+
}
|
|
62
|
+
async currentRevision(slug) {
|
|
63
|
+
try {
|
|
64
|
+
return (await this.wp.get(itemEndpoint(spec, slug))).revision;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (isNotFoundError(error))
|
|
68
|
+
return undefined;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async pushItem({ args, slug }, task) {
|
|
73
|
+
if (this.dryRun) {
|
|
74
|
+
if (task)
|
|
75
|
+
task.output = `[dry-run] Would push: ${slug}`;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
// Conditional write (#234), same as `menu push`: WordPress refuses it (412) if the item
|
|
80
|
+
// changed since this read. Slug, reserved names and code arguments are checked there.
|
|
81
|
+
const expectedRevision = await this.currentRevision(slug);
|
|
82
|
+
const body = { args, slug };
|
|
83
|
+
if (expectedRevision !== undefined)
|
|
84
|
+
body.expectedRevision = expectedRevision;
|
|
85
|
+
await this.wp.post(spec.endpoint, body);
|
|
86
|
+
if (task)
|
|
87
|
+
task.output = `Pushed: ${slug}`;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
this.reportTaskFailure(`Failed to push ${slug}: ${error.message}`, error, task);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return DeclaredPush;
|
|
95
|
+
}
|
|
96
|
+
export function declaredPullCommand(spec, description) {
|
|
97
|
+
class DeclaredPull extends LoopressCommand {
|
|
98
|
+
static args = { path: pathArg(spec) };
|
|
99
|
+
static description = description;
|
|
100
|
+
static enableJsonFlag = true;
|
|
101
|
+
static examples = [`$ lps ${spec.cliName} pull`];
|
|
102
|
+
static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
|
|
103
|
+
async run() {
|
|
104
|
+
const { args } = await this.parse(DeclaredPull);
|
|
105
|
+
const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
|
|
106
|
+
this.log(`Pulling ${spec.plural} from ${this.siteConfig.url}`);
|
|
107
|
+
this.log(`Path: ${path}`);
|
|
108
|
+
const remote = await this.wp.get(spec.endpoint);
|
|
109
|
+
const orphans = await findOrphanedFiles(path, new Set(remote.map(({ slug }) => slug)), { extensions: [fileExtension(spec)], key: basenameKey });
|
|
110
|
+
await this.pullDirectory(path, remote, orphans, {
|
|
111
|
+
dryRunMessage: `Would pull ${pluralize(remote.length, spec.noun, spec.plural)} to ${path}`,
|
|
112
|
+
orphanReason: `in ${path} no longer present on WordPress`,
|
|
113
|
+
pulledMessage: `Pulled ${pluralize(remote.length, spec.noun, spec.plural)} to ${path}`,
|
|
114
|
+
title: ({ slug }) => slug,
|
|
115
|
+
// Only the arguments: the slug is the file name, the revision is push bookkeeping.
|
|
116
|
+
async write(item, dir) {
|
|
117
|
+
await writeFile(join(dir, `${item.slug}${fileExtension(spec)}`), formatDeclaredFile(spec, item.args));
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
return { path, status: this.dryRun ? 'dry-run' : 'success' };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return DeclaredPull;
|
|
124
|
+
}
|
|
125
|
+
// `countLabel` renders the item's count: "3 published" for a post type, "3 terms" for a taxonomy,
|
|
126
|
+
// nothing when it returns an empty string.
|
|
127
|
+
export function declaredListCommand(spec, description, countLabel) {
|
|
128
|
+
class DeclaredList extends LoopressCommand {
|
|
129
|
+
static description = description;
|
|
130
|
+
static enableJsonFlag = true;
|
|
131
|
+
static examples = [`$ lps ${spec.cliName} list`];
|
|
132
|
+
async run() {
|
|
133
|
+
const items = await this.wp.get(spec.registeredEndpoint);
|
|
134
|
+
for (const item of items) {
|
|
135
|
+
const attached = item.objectTypes && item.objectTypes.length > 0 ? `, on ${item.objectTypes.join(', ')}` : '';
|
|
136
|
+
const count = countLabel(item.count);
|
|
137
|
+
const counted = count ? ', ' + count : '';
|
|
138
|
+
this.log(`${item.slug} (${item.label}): ${SOURCE_LABELS[item.source]}${counted}${attached}`);
|
|
139
|
+
if (item.conflict) {
|
|
140
|
+
this.warn(`${item.slug}: declared in ${spec.dir}/ but already registered by ${SOURCE_LABELS[item.source]}, the Loopress version is skipped`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return items;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return DeclaredList;
|
|
147
|
+
}
|
|
148
|
+
// The only way a Loopress item leaves WordPress: push never deletes (see Push Deletion Rules in
|
|
149
|
+
// the product docs). The local file is left alone, same as `hook rm`. `keptNote` says what
|
|
150
|
+
// happens to what used it ("Its posts stay in the database, hidden until it's pushed again.").
|
|
151
|
+
export function declaredRmCommand(spec, description, keptNote) {
|
|
152
|
+
class DeclaredRm extends LoopressCommand {
|
|
153
|
+
static aliases = [`${spec.cliName}:remove`];
|
|
154
|
+
static args = {
|
|
155
|
+
slug: Args.string({ description: `The ${spec.noun} slug, its ${spec.dir}/ file name without ${fileExtension(spec)}`, required: true }),
|
|
156
|
+
};
|
|
157
|
+
static description = description;
|
|
158
|
+
static enableJsonFlag = true;
|
|
159
|
+
static examples = [`$ lps ${spec.cliName} rm <slug>`, `$ lps ${spec.cliName} remove <slug> --yes`];
|
|
160
|
+
static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
|
|
161
|
+
async run() {
|
|
162
|
+
const { args } = await this.parse(DeclaredRm);
|
|
163
|
+
const { slug } = args;
|
|
164
|
+
const { url } = this.siteConfig;
|
|
165
|
+
if (this.dryRun) {
|
|
166
|
+
this.log(`[dry-run] Would remove ${spec.noun} ${slug} from ${url}`);
|
|
167
|
+
return { removed: false, slug, status: 'dry-run' };
|
|
168
|
+
}
|
|
169
|
+
if (!this.yes) {
|
|
170
|
+
if (!isInteractive())
|
|
171
|
+
this.error(`Removing ${spec.noun} ${slug} needs confirmation. Re-run with --yes.`);
|
|
172
|
+
const ok = await confirm({ default: false, message: `Remove ${spec.noun} ${slug} from ${url}? ${keptNote}` });
|
|
173
|
+
if (!ok) {
|
|
174
|
+
this.log('Aborted.');
|
|
175
|
+
return { removed: false, slug, status: 'aborted' };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
await this.wp.delete(itemEndpoint(spec, slug));
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
const notFound = `${spec.noun[0].toUpperCase()}${spec.noun.slice(1)} not found`;
|
|
183
|
+
if (isApplicative404(error, notFound))
|
|
184
|
+
this.error(`${spec.noun} ${slug} is not managed by Loopress on ${url}.`);
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
this.log(`Removed ${spec.noun} ${slug} from ${url}. ${keptNote}`);
|
|
188
|
+
return { removed: true, slug, status: 'success' };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return DeclaredRm;
|
|
192
|
+
}
|
package/dist/lib/dev-watch.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type LoopressLocalConfig } from '../utils/loopress-config.js';
|
|
2
|
-
export type ResourceType = 'api' | 'hooks' | 'pages' | 'parts' | 'plugins' | 'snippets' | 'templates';
|
|
2
|
+
export type ResourceType = 'api' | 'blocks' | 'css' | 'hooks' | 'pages' | 'parts' | 'plugins' | 'snippets' | 'templates';
|
|
3
3
|
export declare const RESOURCE_TYPES: ResourceType[];
|
|
4
4
|
export type WatchTarget = {
|
|
5
5
|
commandId: string;
|
package/dist/lib/dev-watch.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join, sep } from 'node:path';
|
|
3
|
-
|
|
3
|
+
import { resolveResourceDir } from '../utils/resource-dirs.js';
|
|
4
|
+
export const RESOURCE_TYPES = ['snippets', 'templates', 'parts', 'css', 'blocks', 'pages', 'api', 'hooks', 'plugins'];
|
|
4
5
|
// `only` narrows the default set, `skip` then removes from whatever that set is, same
|
|
5
6
|
// precedence a combined `--only=a,b --skip=b` reads as: watch a, b, then drop b.
|
|
6
7
|
export function resolveResourceTypes(only, skip = []) {
|
|
@@ -15,11 +16,14 @@ export function buildWatchTargets(types, localConfig, cwd) {
|
|
|
15
16
|
const rootDir = join(cwd, localConfig.rootDir ?? '.');
|
|
16
17
|
const candidates = {
|
|
17
18
|
api: { commandId: 'api:push', path: join(rootDir, localConfig.apiDir ?? 'api'), type: 'api' },
|
|
19
|
+
// The theme CSS is bundled into the child theme with the templates, by the same push.
|
|
20
|
+
blocks: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('blockCss', localConfig)), type: 'blocks' },
|
|
21
|
+
css: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('css', localConfig)), type: 'css' },
|
|
18
22
|
hooks: { commandId: 'hook:push', path: join(rootDir, localConfig.hooksDir ?? 'hooks'), type: 'hooks' },
|
|
19
23
|
pages: { commandId: 'page:push', path: join(rootDir, localConfig.pageDir ?? 'pages'), type: 'pages' },
|
|
20
24
|
plugins: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugins' },
|
|
21
|
-
parts: { commandId: 'theme:template:push', path: join(
|
|
22
|
-
templates: { commandId: 'theme:template:push', path: join(
|
|
25
|
+
parts: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('part', localConfig)), type: 'parts' },
|
|
26
|
+
templates: { commandId: 'theme:template:push', path: join(cwd, resolveResourceDir('template', localConfig)), type: 'templates' },
|
|
23
27
|
snippets: { commandId: 'snippet:push', path: join(rootDir, localConfig.snippetsDir ?? 'snippets'), type: 'snippets' },
|
|
24
28
|
};
|
|
25
29
|
return types
|
|
@@ -4,6 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { withSpinner } from './spinner.js';
|
|
7
8
|
const REPO = 'loopress/loopress';
|
|
8
9
|
const ASSET_NAME = 'loopress-full.zip';
|
|
9
10
|
/**
|
|
@@ -17,6 +18,9 @@ const ASSET_NAME = 'loopress-full.zip';
|
|
|
17
18
|
* "the one zip asset".
|
|
18
19
|
*/
|
|
19
20
|
export async function downloadLatestFullZip() {
|
|
21
|
+
return withSpinner('Downloading Loopress Full', download);
|
|
22
|
+
}
|
|
23
|
+
async function download() {
|
|
20
24
|
const releases = await got(`https://api.github.com/repos/${REPO}/releases`, {
|
|
21
25
|
headers: { accept: 'application/vnd.github+json', 'user-agent': 'loopress-cli' },
|
|
22
26
|
searchParams: { 'per_page': 30 },
|
|
@@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer';
|
|
|
2
2
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
4
|
import { openBrowser } from './open-browser.js';
|
|
5
|
+
import { withSpinner } from './spinner.js';
|
|
5
6
|
/** Constant-time compare that also tolerates a length mismatch without throwing. */
|
|
6
7
|
function safeEqual(a, b) {
|
|
7
8
|
const bufferA = Buffer.from(a);
|
|
@@ -28,7 +29,7 @@ function safeEqual(a, b) {
|
|
|
28
29
|
export async function waitForLocalCallback(options) {
|
|
29
30
|
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
|
|
30
31
|
const state = randomBytes(32).toString('hex');
|
|
31
|
-
|
|
32
|
+
const callback = new Promise((resolve, reject) => {
|
|
32
33
|
function finish(res, page) {
|
|
33
34
|
// `Connection: close` (here and below): server.close() only refuses new connections, a
|
|
34
35
|
// kept-alive socket would otherwise still reach this handler after the shutdown.
|
|
@@ -100,6 +101,7 @@ export async function waitForLocalCallback(options) {
|
|
|
100
101
|
openBrowser(targetUrl);
|
|
101
102
|
});
|
|
102
103
|
});
|
|
104
|
+
return withSpinner('Waiting for the browser', async () => callback);
|
|
103
105
|
}
|
|
104
106
|
/**
|
|
105
107
|
* The page rewrites its own URL to `/` on load, so the callback query string (an Application
|
|
@@ -15,6 +15,7 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
15
15
|
protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<void>;
|
|
16
16
|
protected recordDeployment(status: 'failure' | 'success'): Promise<void>;
|
|
17
17
|
protected recordSuccess(): Promise<void>;
|
|
18
|
+
protected reportPushToSite(): Promise<void>;
|
|
18
19
|
protected reportTaskFailure(message: string, error: unknown, task?: {
|
|
19
20
|
output: string;
|
|
20
21
|
}): never;
|
package/dist/lib/push-command.js
CHANGED
|
@@ -7,6 +7,7 @@ import { API_URL } from './api-client.js';
|
|
|
7
7
|
import { LoopressCommand } from './base.js';
|
|
8
8
|
import { guardProductionPush } from './guard-production-push.js';
|
|
9
9
|
import { writeSnapshot } from './snapshot-store.js';
|
|
10
|
+
import { withoutSpinner, withSpinner } from './spinner.js';
|
|
10
11
|
export class PushCommand extends LoopressCommand {
|
|
11
12
|
// Passed by an aggregate command (`lps theme push`) that already confirmed production once,
|
|
12
13
|
// so its steps skip that prompt while still asking their own confirmations (uninstalls).
|
|
@@ -78,19 +79,37 @@ export class PushCommand extends LoopressCommand {
|
|
|
78
79
|
if (!token)
|
|
79
80
|
return;
|
|
80
81
|
try {
|
|
81
|
-
await got.post(`${API_URL}/deployments`, {
|
|
82
|
+
await withSpinner('Recording the deployment', async () => got.post(`${API_URL}/deployments`, {
|
|
82
83
|
headers: { Authorization: `Bearer ${token}` },
|
|
83
84
|
json: { status, url: this.siteConfig.url },
|
|
84
85
|
timeout: { request: 3000 },
|
|
85
|
-
});
|
|
86
|
+
}));
|
|
86
87
|
}
|
|
87
88
|
catch {
|
|
88
89
|
// non-blocking: recording must never interrupt the push flow
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
async recordSuccess() {
|
|
92
|
-
if (
|
|
93
|
-
|
|
93
|
+
if (this.dryRun)
|
|
94
|
+
return;
|
|
95
|
+
await this.recordDeployment('success');
|
|
96
|
+
await this.reportPushToSite();
|
|
97
|
+
}
|
|
98
|
+
// Tells the plugin this push finished, for its Overview tab and the "managed by Loopress"
|
|
99
|
+
// warnings in wp-admin: one record per command (`this.id`, e.g. `acf:push`), since a single push
|
|
100
|
+
// sends one request per item. Best-effort like recordDeployment: a plugin older than the push
|
|
101
|
+
// log answers 404, one that doesn't know this command 400, neither may fail a push that landed.
|
|
102
|
+
async reportPushToSite() {
|
|
103
|
+
if (!this.id)
|
|
104
|
+
return;
|
|
105
|
+
try {
|
|
106
|
+
// Bounded: the site fingerprints the pushed resource before answering, which a large
|
|
107
|
+
// site can make slow, and a finished push must not wait on its own bookkeeping.
|
|
108
|
+
await this.wp.post('loopress/v1/pushes', { resource: this.id }, { timeoutMs: 5000 });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// non-blocking, see above
|
|
112
|
+
}
|
|
94
113
|
}
|
|
95
114
|
// Reports one failed Listr task: status lines go through `task.output` when running inside a
|
|
96
115
|
// Listr renderer (this.warn would race with the repaint), and fall back to `this.warn` when
|
|
@@ -111,17 +130,19 @@ export class PushCommand extends LoopressCommand {
|
|
|
111
130
|
// just the first. `label` feeds the "Push <label>" task title; `task` does the actual work
|
|
112
131
|
// and reports its own failure through `reportTaskFailure`.
|
|
113
132
|
async runPushTasks(items, label, task) {
|
|
114
|
-
await
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
133
|
+
await withoutSpinner(async () => {
|
|
134
|
+
await new Listr(items.map((item) => ({
|
|
135
|
+
task: async (_ctx, taskCtx) => task(item, taskCtx),
|
|
136
|
+
title: `Push ${label(item)}`,
|
|
137
|
+
})), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
|
|
138
|
+
});
|
|
118
139
|
}
|
|
119
140
|
// Pairs `beforeState` (from captureBeforePushState, read before this push touched anything)
|
|
120
141
|
// with a fresh remote read taken right after the push, and writes both as one rollback
|
|
121
142
|
// snapshot. Reading the after-state from WordPress itself, rather than assuming it matches
|
|
122
143
|
// the local files that were pushed, is what keeps this correct for every resource's own
|
|
123
|
-
// partial-push semantics: a readonly option skipped by `option push`, a snippet or
|
|
124
|
-
//
|
|
144
|
+
// partial-push semantics: a readonly option skipped by `option push`, a snippet or form
|
|
145
|
+
// that gets a server-assigned id on create, a theme-styles push that only touches the
|
|
125
146
|
// active stylesheet, an API/hook push that leaves server-side files alone unless `--prune`.
|
|
126
147
|
// `lps <resource> rollback`'s drift check compares the environment's state at rollback time
|
|
127
148
|
// against exactly this after-state, so it has to be what actually landed, not what was sent.
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { isNotFoundError } from './wp-client.js';
|
|
2
|
-
// Update-or-create dance shared by every id-based resource (form,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// database reset.
|
|
2
|
+
// Update-or-create dance shared by every id-based resource (form, snippet, unlike ACF which
|
|
3
|
+
// is key-based and only ever POSTs): PUT to the known id, and only fall back to POST when the
|
|
4
|
+
// site doesn't recognize that id (404), e.g. a fresh install or a database reset.
|
|
6
5
|
export async function putOrCreate(wp, options) {
|
|
7
6
|
const { id, payload, postEndpoint, postPayload = payload, putEndpoint } = options;
|
|
8
7
|
if (id !== null) {
|