@loopress/cli 0.18.0 → 0.20.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 +260 -75
- package/dist/commands/acf/pull.d.ts +1 -1
- package/dist/commands/acf/pull.js +12 -25
- package/dist/commands/acf/push.d.ts +1 -1
- package/dist/commands/acf/push.js +18 -47
- package/dist/commands/api/publish.d.ts +9 -0
- package/dist/commands/api/publish.js +60 -0
- package/dist/commands/api/pull.d.ts +1 -1
- package/dist/commands/api/pull.js +11 -26
- package/dist/commands/api/push.d.ts +2 -1
- package/dist/commands/api/push.js +40 -27
- package/dist/commands/composer/init.js +11 -1
- package/dist/commands/composer/push.d.ts +1 -0
- package/dist/commands/composer/push.js +17 -4
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.js +79 -0
- package/dist/commands/form/pull.d.ts +1 -2
- package/dist/commands/form/pull.js +13 -32
- package/dist/commands/form/push.d.ts +1 -1
- package/dist/commands/form/push.js +6 -14
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +65 -33
- package/dist/commands/page/list.d.ts +9 -0
- package/dist/commands/page/list.js +26 -0
- package/dist/commands/page/pull.d.ts +13 -0
- package/dist/commands/page/pull.js +65 -0
- package/dist/commands/page/push.d.ts +16 -0
- package/dist/commands/page/push.js +145 -0
- package/dist/commands/plugin/push.d.ts +1 -1
- package/dist/commands/plugin/push.js +2 -14
- package/dist/commands/project/config.d.ts +1 -0
- package/dist/commands/project/config.js +32 -0
- package/dist/commands/project/push.d.ts +5 -0
- package/dist/commands/project/push.js +21 -11
- package/dist/commands/project/rotate.d.ts +7 -0
- package/dist/commands/project/rotate.js +20 -0
- package/dist/commands/seo/pull.d.ts +1 -3
- package/dist/commands/seo/pull.js +15 -43
- package/dist/commands/seo/push.d.ts +1 -1
- package/dist/commands/seo/push.js +4 -17
- package/dist/commands/snippet/publish.js +2 -2
- package/dist/commands/snippet/pull.d.ts +1 -4
- package/dist/commands/snippet/pull.js +12 -60
- package/dist/commands/snippet/push.d.ts +1 -1
- package/dist/commands/snippet/push.js +4 -13
- package/dist/commands/status.d.ts +4 -0
- package/dist/commands/status.js +25 -4
- package/dist/config/project-config.manager.js +2 -2
- package/dist/lib/api-client.d.ts +2 -1
- package/dist/lib/api-client.js +10 -3
- package/dist/lib/base.d.ts +12 -0
- package/dist/lib/base.js +100 -9
- package/dist/lib/bootstrap-full-install.d.ts +10 -0
- package/dist/lib/bootstrap-full-install.js +66 -0
- package/dist/lib/browser-launch.d.ts +8 -0
- package/dist/lib/browser-launch.js +20 -0
- package/dist/lib/find-orphaned-files.d.ts +7 -0
- package/dist/lib/find-orphaned-files.js +29 -0
- package/dist/lib/github-release.d.ts +11 -0
- package/dist/lib/github-release.js +37 -0
- package/dist/lib/interactive.d.ts +1 -0
- package/dist/lib/interactive.js +7 -0
- package/dist/lib/load-files.d.ts +5 -0
- package/dist/lib/load-files.js +31 -0
- package/dist/lib/plugin-detection.d.ts +8 -0
- package/dist/lib/plugin-detection.js +11 -0
- package/dist/lib/push-command.d.ts +7 -0
- package/dist/lib/push-command.js +40 -2
- package/dist/lib/rotate-app-password.d.ts +6 -0
- package/dist/lib/rotate-app-password.js +33 -0
- package/dist/lib/temp-admin.d.ts +19 -0
- package/dist/lib/temp-admin.js +46 -0
- package/dist/lib/wp-client.d.ts +10 -4
- package/dist/lib/wp-client.js +29 -12
- package/dist/types/project-config.generated.d.ts +4 -0
- package/dist/utils/composer.d.ts +3 -0
- package/dist/utils/page-format.d.ts +7 -0
- package/dist/utils/page-format.js +78 -0
- package/dist/utils/seo-format.d.ts +1 -0
- package/dist/utils/seo-format.js +5 -0
- package/dist/utils/snippet-format.d.ts +2 -0
- package/dist/utils/snippet-format.js +27 -1
- package/dist/utils/to-slug.d.ts +1 -0
- package/dist/utils/to-slug.js +7 -0
- package/oclif.manifest.json +688 -29
- package/package.json +16 -9
- package/schemas/project-config.schema.json +5 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import
|
|
3
|
+
import { toSlug } from '../utils/to-slug.js';
|
|
4
4
|
import { readJsonFile, writeJsonFileAtomic } from './json-file.js';
|
|
5
5
|
export class ProjectConfigManager {
|
|
6
6
|
configDir;
|
|
@@ -9,7 +9,7 @@ export class ProjectConfigManager {
|
|
|
9
9
|
}
|
|
10
10
|
createProjectId(name) {
|
|
11
11
|
const config = this.readConfig();
|
|
12
|
-
const base =
|
|
12
|
+
const base = toSlug(name, 'project');
|
|
13
13
|
let id = base;
|
|
14
14
|
let suffix = 2;
|
|
15
15
|
while (config.projects[id]) {
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -6,7 +6,8 @@ export declare const API_URL: string;
|
|
|
6
6
|
export declare class ApiClient {
|
|
7
7
|
private readonly baseUrl;
|
|
8
8
|
private readonly client;
|
|
9
|
-
|
|
9
|
+
private readonly timeoutMs;
|
|
10
|
+
constructor(token: string, baseUrl?: string, timeoutMs?: number);
|
|
10
11
|
get<T = unknown>(path: string): Promise<T>;
|
|
11
12
|
post<T = unknown>(path: string, json?: Record<string, unknown>): Promise<T>;
|
|
12
13
|
put<T = unknown>(path: string, json?: Record<string, unknown>): Promise<T>;
|
package/dist/lib/api-client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import got from 'got';
|
|
2
|
+
import { REQUEST_TIMEOUT_MS } from './wp-client.js';
|
|
2
3
|
export const API_URL = process.env.LPS_API_URL ?? 'https://api.loopress.dev';
|
|
3
4
|
/**
|
|
4
5
|
* HTTP client for the Loopress cloud API (projects, environments, credentials).
|
|
@@ -7,11 +8,14 @@ export const API_URL = process.env.LPS_API_URL ?? 'https://api.loopress.dev';
|
|
|
7
8
|
export class ApiClient {
|
|
8
9
|
baseUrl;
|
|
9
10
|
client;
|
|
10
|
-
|
|
11
|
+
timeoutMs;
|
|
12
|
+
constructor(token, baseUrl = API_URL, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
11
13
|
this.baseUrl = baseUrl;
|
|
14
|
+
this.timeoutMs = timeoutMs;
|
|
12
15
|
this.client = got.extend({
|
|
13
16
|
headers: { Authorization: `Bearer ${token}` },
|
|
14
17
|
prefixUrl: baseUrl,
|
|
18
|
+
timeout: { request: timeoutMs },
|
|
15
19
|
});
|
|
16
20
|
}
|
|
17
21
|
async get(path) {
|
|
@@ -29,11 +33,11 @@ export class ApiClient {
|
|
|
29
33
|
return (response.body ? JSON.parse(response.body) : undefined);
|
|
30
34
|
}
|
|
31
35
|
catch (error) {
|
|
32
|
-
throw new Error(formatApiError(error, `${this.baseUrl}/${path}
|
|
36
|
+
throw new Error(formatApiError(error, `${this.baseUrl}/${path}`, this.timeoutMs), { cause: error });
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
|
-
function formatApiError(error, url) {
|
|
40
|
+
function formatApiError(error, url, timeoutMs) {
|
|
37
41
|
const err = error;
|
|
38
42
|
const status = err.response?.statusCode;
|
|
39
43
|
if (status === 401) {
|
|
@@ -42,5 +46,8 @@ function formatApiError(error, url) {
|
|
|
42
46
|
if (status === 403) {
|
|
43
47
|
return `Request rejected (${status}) on ${url}: ${err.response?.body ?? err.message}`;
|
|
44
48
|
}
|
|
49
|
+
if (err.name === 'TimeoutError') {
|
|
50
|
+
return `Request timed out after ${timeoutMs / 1000}s on ${url}. Check your network connection, or the Loopress API may be temporarily unreachable.`;
|
|
51
|
+
}
|
|
45
52
|
return err.message ?? String(error);
|
|
46
53
|
}
|
package/dist/lib/base.d.ts
CHANGED
|
@@ -3,20 +3,32 @@ import { EnvironmentConfig } from '../types/config.js';
|
|
|
3
3
|
import { LoopressLocalConfig } from '../utils/loopress-config.js';
|
|
4
4
|
import { WpClient } from './wp-client.js';
|
|
5
5
|
export declare abstract class LoopressCommand extends Command {
|
|
6
|
+
static baseFlags: {
|
|
7
|
+
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
};
|
|
6
9
|
static dryRunFlag: {
|
|
7
10
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
11
|
};
|
|
12
|
+
static yesFlag: {
|
|
13
|
+
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
|
+
};
|
|
9
15
|
protected dryRun: boolean;
|
|
10
16
|
protected localConfig: LoopressLocalConfig;
|
|
17
|
+
protected projectId: string;
|
|
11
18
|
protected siteConfig: EnvironmentConfig;
|
|
19
|
+
protected yes: boolean;
|
|
12
20
|
private wpClient?;
|
|
13
21
|
protected get rootDir(): string;
|
|
14
22
|
protected get wp(): WpClient;
|
|
15
23
|
init(): Promise<void>;
|
|
24
|
+
protected maybeAutoRotate(): Promise<void>;
|
|
25
|
+
protected removeOrphanedFiles(dir: string, orphans: string[], reason: string): Promise<void>;
|
|
16
26
|
protected resolveAcfPath(override?: string): string;
|
|
17
27
|
protected resolveApiPath(override?: string): string;
|
|
18
28
|
protected resolveFormPath(override?: string): string;
|
|
29
|
+
protected resolvePagePath(override?: string): string;
|
|
19
30
|
protected resolveSeoPath(override?: string): string;
|
|
20
31
|
protected resolveSnippetsPath(override?: string): string;
|
|
32
|
+
private pickEnvironment;
|
|
21
33
|
private resolveEnvironment;
|
|
22
34
|
}
|
package/dist/lib/base.js
CHANGED
|
@@ -1,15 +1,31 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
1
2
|
import { Command, Flags } from '@oclif/core';
|
|
3
|
+
import { rm } from 'node:fs/promises';
|
|
2
4
|
import { join } from 'node:path';
|
|
3
5
|
import { configManager } from '../config/project-config.manager.js';
|
|
4
6
|
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
7
|
+
import { isInteractive } from './interactive.js';
|
|
8
|
+
import { isAppPasswordStale, rotateAppPassword } from './rotate-app-password.js';
|
|
5
9
|
import { WpClient } from './wp-client.js';
|
|
6
10
|
export class LoopressCommand extends Command {
|
|
11
|
+
// On every subclass without opt-in: targeting an environment explicitly beats depending on
|
|
12
|
+
// the machine-wide mutable state of `lps project switch`, which is shared across terminals.
|
|
13
|
+
static baseFlags = {
|
|
14
|
+
env: Flags.string({
|
|
15
|
+
description: 'Target environment by name, overriding the globally active one (lps project switch)',
|
|
16
|
+
}),
|
|
17
|
+
};
|
|
7
18
|
static dryRunFlag = {
|
|
8
19
|
'dry-run': Flags.boolean({ char: 'd', description: 'Show what would change without making changes' }),
|
|
9
20
|
};
|
|
21
|
+
static yesFlag = {
|
|
22
|
+
yes: Flags.boolean({ char: 'y', description: 'Answer yes to confirmation prompts' }),
|
|
23
|
+
};
|
|
10
24
|
dryRun = false;
|
|
11
25
|
localConfig = {};
|
|
26
|
+
projectId;
|
|
12
27
|
siteConfig;
|
|
28
|
+
yes = false;
|
|
13
29
|
wpClient;
|
|
14
30
|
get rootDir() {
|
|
15
31
|
return this.localConfig.rootDir ?? '.';
|
|
@@ -28,12 +44,64 @@ export class LoopressCommand extends Command {
|
|
|
28
44
|
await super.init();
|
|
29
45
|
const { flags } = (await this.parse({
|
|
30
46
|
args: this.ctor.args,
|
|
47
|
+
baseFlags: this.ctor.baseFlags,
|
|
31
48
|
flags: this.ctor.flags,
|
|
32
49
|
strict: this.ctor.strict,
|
|
33
50
|
}));
|
|
34
51
|
this.dryRun = Boolean(flags['dry-run']);
|
|
52
|
+
this.yes = Boolean(flags.yes);
|
|
35
53
|
this.localConfig = await readLocalConfig();
|
|
36
|
-
|
|
54
|
+
const resolved = this.resolveEnvironment(flags.env);
|
|
55
|
+
this.siteConfig = resolved.env;
|
|
56
|
+
this.projectId = resolved.projectId;
|
|
57
|
+
await this.maybeAutoRotate();
|
|
58
|
+
}
|
|
59
|
+
// Silent, best-effort: a stale app password still works, so a failed rotation attempt
|
|
60
|
+
// (offline, site down) just tries again on the next command instead of blocking this one.
|
|
61
|
+
// `dryRun` is excluded since rotating writes a new credential, a real side effect a
|
|
62
|
+
// --dry-run run promises not to cause. `Rotate` overrides this to a no-op: its own `run()`
|
|
63
|
+
// already rotates unconditionally, so the background check would only redo the same work.
|
|
64
|
+
async maybeAutoRotate() {
|
|
65
|
+
if (this.dryRun)
|
|
66
|
+
return;
|
|
67
|
+
const { token } = this.siteConfig;
|
|
68
|
+
if (!token || !isAppPasswordStale(this.siteConfig.addedAt))
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
const rotated = await rotateAppPassword({ ...this.siteConfig, token });
|
|
72
|
+
this.siteConfig = rotated;
|
|
73
|
+
configManager.setEnvironment(this.projectId, rotated.name, rotated);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// swallowed: see comment above
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Orphan cleanup shared by every pull command (see Push Deletion Rules in the product docs):
|
|
80
|
+
// in a TTY the list is shown and confirmed first (Enter accepts), --yes skips the prompt, and
|
|
81
|
+
// outside a TTY the files are removed with a warning, the behavior scripts relied on before
|
|
82
|
+
// the confirmation existed.
|
|
83
|
+
async removeOrphanedFiles(dir, orphans, reason) {
|
|
84
|
+
if (orphans.length === 0)
|
|
85
|
+
return;
|
|
86
|
+
const description = `${orphans.length} local file${orphans.length === 1 ? '' : 's'} ${reason}: ${orphans.join(', ')}`;
|
|
87
|
+
if (!this.yes && isInteractive()) {
|
|
88
|
+
const proceed = await confirm({ default: true, message: `Remove ${description}?` });
|
|
89
|
+
if (!proceed) {
|
|
90
|
+
this.log(`Kept ${description}`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const file of orphans)
|
|
95
|
+
await rm(join(dir, file), { force: true });
|
|
96
|
+
// Non-interactive removals (no TTY, CI) keep the original warn so existing
|
|
97
|
+
// scripts that parse stderr or check exit status are not broken. Confirmed
|
|
98
|
+
// interactive removals are intentional, so log() is appropriate.
|
|
99
|
+
if (this.yes || isInteractive()) {
|
|
100
|
+
this.log(`Removed ${description}`);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
this.warn(`Removed ${description}`);
|
|
104
|
+
}
|
|
37
105
|
}
|
|
38
106
|
resolveAcfPath(override) {
|
|
39
107
|
if (override)
|
|
@@ -50,6 +118,11 @@ export class LoopressCommand extends Command {
|
|
|
50
118
|
return override;
|
|
51
119
|
return join(this.rootDir, this.localConfig.formDir ?? 'forms');
|
|
52
120
|
}
|
|
121
|
+
resolvePagePath(override) {
|
|
122
|
+
if (override)
|
|
123
|
+
return override;
|
|
124
|
+
return join(this.rootDir, this.localConfig.pageDir ?? 'pages');
|
|
125
|
+
}
|
|
53
126
|
resolveSeoPath(override) {
|
|
54
127
|
if (override)
|
|
55
128
|
return override;
|
|
@@ -60,29 +133,47 @@ export class LoopressCommand extends Command {
|
|
|
60
133
|
return override;
|
|
61
134
|
return join(this.rootDir, this.localConfig.snippetsDir ?? 'snippets');
|
|
62
135
|
}
|
|
63
|
-
|
|
136
|
+
pickEnvironment(project, envName) {
|
|
137
|
+
const env = project.environments[envName];
|
|
138
|
+
if (!env) {
|
|
139
|
+
this.error(`Environment "${envName}" not found in project "${project.name}". Available: ${Object.keys(project.environments).join(', ')}`);
|
|
140
|
+
}
|
|
141
|
+
return env;
|
|
142
|
+
}
|
|
143
|
+
resolveEnvironment(envName) {
|
|
64
144
|
if (this.localConfig.projectId) {
|
|
65
|
-
const
|
|
145
|
+
const { projectId } = this.localConfig;
|
|
146
|
+
const project = configManager.getProject(projectId);
|
|
66
147
|
if (!project) {
|
|
67
|
-
this.error(`Project "${
|
|
148
|
+
this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
|
|
68
149
|
}
|
|
69
150
|
const envNames = Object.keys(project.environments);
|
|
70
151
|
if (envNames.length === 0) {
|
|
71
152
|
this.error(`Project "${project.name}" has no environments configured. Run \`lps project config\` to add one.`);
|
|
72
153
|
}
|
|
154
|
+
if (envName)
|
|
155
|
+
return { env: this.pickEnvironment(project, envName), projectId };
|
|
73
156
|
if (envNames.length === 1) {
|
|
74
|
-
return project.environments[envNames[0]];
|
|
157
|
+
return { env: project.environments[envNames[0]], projectId };
|
|
75
158
|
}
|
|
76
159
|
const current = configManager.getCurrentProject();
|
|
77
|
-
const currentEnv = current?.id ===
|
|
160
|
+
const currentEnv = current?.id === projectId ? configManager.getCurrentEnv() : null;
|
|
78
161
|
if (!currentEnv) {
|
|
79
162
|
this.error(`Project "${project.name}" has multiple environments. Run \`lps project switch\` to pick one.`);
|
|
80
163
|
}
|
|
81
|
-
return currentEnv;
|
|
164
|
+
return { env: currentEnv, projectId };
|
|
165
|
+
}
|
|
166
|
+
if (envName) {
|
|
167
|
+
const current = configManager.getCurrentProject();
|
|
168
|
+
if (!current) {
|
|
169
|
+
this.error('No project configured. Run `lps project config` first.');
|
|
170
|
+
}
|
|
171
|
+
return { env: this.pickEnvironment(current, envName), projectId: current.id };
|
|
82
172
|
}
|
|
83
173
|
const env = configManager.getCurrentEnv();
|
|
84
|
-
|
|
85
|
-
|
|
174
|
+
const current = configManager.getCurrentProject();
|
|
175
|
+
if (env && current)
|
|
176
|
+
return { env, projectId: current.id };
|
|
86
177
|
this.error('No environment configured. Run `lps project config` first.');
|
|
87
178
|
}
|
|
88
179
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { WpClient } from './wp-client.js';
|
|
2
|
+
/**
|
|
3
|
+
* Installs and activates Loopress Full on a site that doesn't have it yet: downloads the
|
|
4
|
+
* latest release zip, creates a temporary admin (the only way to get `install_plugins` from
|
|
5
|
+
* just an app password), drives the wp-admin upload UI headlessly, then removes the temp
|
|
6
|
+
* account. Cleanup always runs, whether the install succeeded or not: the temp account never
|
|
7
|
+
* stays alive waiting on a human, the fallback for a failed install is manual instructions
|
|
8
|
+
* using the real user's own credentials, not the temp account.
|
|
9
|
+
*/
|
|
10
|
+
export declare function bootstrapLoopressFull(wp: WpClient, siteUrl: string, log: (message: string) => void): Promise<void>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { launchLocalBrowser } from './browser-launch.js';
|
|
2
|
+
import { downloadLatestFullZip } from './github-release.js';
|
|
3
|
+
import { createTempAdmin, deleteTempAdmin } from './temp-admin.js';
|
|
4
|
+
/**
|
|
5
|
+
* Installs and activates Loopress Full on a site that doesn't have it yet: downloads the
|
|
6
|
+
* latest release zip, creates a temporary admin (the only way to get `install_plugins` from
|
|
7
|
+
* just an app password), drives the wp-admin upload UI headlessly, then removes the temp
|
|
8
|
+
* account. Cleanup always runs, whether the install succeeded or not: the temp account never
|
|
9
|
+
* stays alive waiting on a human, the fallback for a failed install is manual instructions
|
|
10
|
+
* using the real user's own credentials, not the temp account.
|
|
11
|
+
*/
|
|
12
|
+
export async function bootstrapLoopressFull(wp, siteUrl, log) {
|
|
13
|
+
log('Downloading the latest Loopress Full release...');
|
|
14
|
+
const zipPath = await downloadLatestFullZip();
|
|
15
|
+
log('Creating a temporary admin account to install it...');
|
|
16
|
+
const admin = await createTempAdmin(wp);
|
|
17
|
+
let installError;
|
|
18
|
+
try {
|
|
19
|
+
log('Installing and activating Loopress Full...');
|
|
20
|
+
await runBrowserInstall(admin, siteUrl, zipPath);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
installError = error;
|
|
24
|
+
}
|
|
25
|
+
if (!installError) {
|
|
26
|
+
log('Loopress Full installed and activated.');
|
|
27
|
+
}
|
|
28
|
+
log('Removing the temporary admin account...');
|
|
29
|
+
let cleanupError;
|
|
30
|
+
try {
|
|
31
|
+
await deleteTempAdmin(wp, admin);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
cleanupError = error;
|
|
35
|
+
}
|
|
36
|
+
const manualFallback = `Install it manually: upload ${zipPath} at ${siteUrl}/wp-admin/plugin-install.php?tab=upload`;
|
|
37
|
+
if (installError && cleanupError) {
|
|
38
|
+
throw new Error(`Could not install Loopress Full automatically, and the temporary admin account could not be removed (${cleanupError.message}). ${manualFallback}`, { cause: installError });
|
|
39
|
+
}
|
|
40
|
+
if (cleanupError)
|
|
41
|
+
throw cleanupError;
|
|
42
|
+
if (installError) {
|
|
43
|
+
throw new Error(`Could not install Loopress Full automatically. ${manualFallback}`, { cause: installError });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function runBrowserInstall(admin, siteUrl, zipPath) {
|
|
47
|
+
const browser = await launchLocalBrowser();
|
|
48
|
+
try {
|
|
49
|
+
const page = await browser.newPage();
|
|
50
|
+
await page.goto(`${siteUrl}/wp-login.php`, { waitUntil: 'domcontentloaded' });
|
|
51
|
+
await page.fill('#user_login', admin.username);
|
|
52
|
+
await page.fill('#user_pass', admin.password);
|
|
53
|
+
await Promise.all([page.waitForLoadState('domcontentloaded'), page.click('#wp-submit')]);
|
|
54
|
+
await page.goto(`${siteUrl}/wp-admin/plugin-install.php?tab=upload`, { waitUntil: 'domcontentloaded' });
|
|
55
|
+
await page.setInputFiles('#pluginzip', zipPath);
|
|
56
|
+
await Promise.all([page.waitForLoadState('domcontentloaded'), page.click('input[name="install-plugin-submit"]')]);
|
|
57
|
+
// The activate link only appears after a successful install; its absence (a moved
|
|
58
|
+
// selector, DISALLOW_FILE_MODS, an unexpected wp-admin state) means the install didn't
|
|
59
|
+
// go through, not that activation is a separate optional step.
|
|
60
|
+
const activateLink = await page.waitForSelector('a[href*="action=activate"]', { timeout: 15_000 });
|
|
61
|
+
await Promise.all([page.waitForLoadState('domcontentloaded'), activateLink.click()]);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
await browser.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Browser } from 'playwright-core';
|
|
2
|
+
/**
|
|
3
|
+
* Launches a locally installed Chrome/Edge/Chromium via `playwright-core`, which ships no
|
|
4
|
+
* bundled browser (unlike `playwright`, ~300MB of Chromium): it drives whatever's already on
|
|
5
|
+
* the machine, tried in this order. Always headless: this flow has no click for a human to
|
|
6
|
+
* make, so there's nothing a visible window would let anyone do differently.
|
|
7
|
+
*/
|
|
8
|
+
export declare function launchLocalBrowser(): Promise<Browser>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { chromium } from 'playwright-core';
|
|
2
|
+
const CHANNELS = ['chrome', 'msedge', 'chromium'];
|
|
3
|
+
/**
|
|
4
|
+
* Launches a locally installed Chrome/Edge/Chromium via `playwright-core`, which ships no
|
|
5
|
+
* bundled browser (unlike `playwright`, ~300MB of Chromium): it drives whatever's already on
|
|
6
|
+
* the machine, tried in this order. Always headless: this flow has no click for a human to
|
|
7
|
+
* make, so there's nothing a visible window would let anyone do differently.
|
|
8
|
+
*/
|
|
9
|
+
export async function launchLocalBrowser() {
|
|
10
|
+
let lastError;
|
|
11
|
+
for (const channel of CHANNELS) {
|
|
12
|
+
try {
|
|
13
|
+
return await chromium.launch({ channel, headless: true });
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
lastError = error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
throw new Error('No local Chrome, Edge, or Chromium install found. Install one of these browsers to install Loopress Full automatically, or install it manually from wp-admin.', { cause: lastError });
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface OrphanMatcher {
|
|
2
|
+
extensions: string[];
|
|
3
|
+
key(base: string): null | string;
|
|
4
|
+
}
|
|
5
|
+
export declare function findOrphanedFiles(dir: string, keep: Set<string>, matcher: OrphanMatcher): Promise<string[]>;
|
|
6
|
+
export declare function numericPrefixKey(base: string): null | string;
|
|
7
|
+
export declare function basenameKey(base: string): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
export async function findOrphanedFiles(dir, keep, matcher) {
|
|
4
|
+
let files;
|
|
5
|
+
try {
|
|
6
|
+
files = await readdir(dir);
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
if (error.code === 'ENOENT')
|
|
10
|
+
return [];
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
return files.filter((file) => {
|
|
14
|
+
const ext = extname(file);
|
|
15
|
+
if (!matcher.extensions.includes(ext))
|
|
16
|
+
return false;
|
|
17
|
+
const key = matcher.key(file.slice(0, -ext.length));
|
|
18
|
+
return key !== null && !keep.has(key);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
// The `<id>-<slug>.*` filename convention: the numeric prefix is the identity.
|
|
22
|
+
export function numericPrefixKey(base) {
|
|
23
|
+
const match = /^(\d+)-/.exec(base);
|
|
24
|
+
return match?.[1] ?? null;
|
|
25
|
+
}
|
|
26
|
+
// The `<key>.<ext>` filename convention: the whole basename is the identity.
|
|
27
|
+
export function basenameKey(base) {
|
|
28
|
+
return base;
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downloads `loopress-full.zip` from the most recent `wordpress-plugin@*` release of
|
|
3
|
+
* `loopress/loopress`, returning the local path to the downloaded file.
|
|
4
|
+
*
|
|
5
|
+
* `releases/latest` isn't used on purpose: this repo's releases interleave
|
|
6
|
+
* `wordpress-plugin@X.Y.Z` and `@loopress/cli@X.Y.Z` tags, so "latest" can be a CLI release
|
|
7
|
+
* with no plugin assets at all. Every `wordpress-plugin@*` release always ships both
|
|
8
|
+
* `loopress-full.zip` and `loopress-light.zip`, so the asset is matched by exact name, not by
|
|
9
|
+
* "the one zip asset".
|
|
10
|
+
*/
|
|
11
|
+
export declare function downloadLatestFullZip(): Promise<string>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import got from 'got';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
const REPO = 'loopress/loopress';
|
|
8
|
+
const RELEASE_TAG_PATTERN = /^wordpress-plugin@/;
|
|
9
|
+
const ASSET_NAME = 'loopress-full.zip';
|
|
10
|
+
/**
|
|
11
|
+
* Downloads `loopress-full.zip` from the most recent `wordpress-plugin@*` release of
|
|
12
|
+
* `loopress/loopress`, returning the local path to the downloaded file.
|
|
13
|
+
*
|
|
14
|
+
* `releases/latest` isn't used on purpose: this repo's releases interleave
|
|
15
|
+
* `wordpress-plugin@X.Y.Z` and `@loopress/cli@X.Y.Z` tags, so "latest" can be a CLI release
|
|
16
|
+
* with no plugin assets at all. Every `wordpress-plugin@*` release always ships both
|
|
17
|
+
* `loopress-full.zip` and `loopress-light.zip`, so the asset is matched by exact name, not by
|
|
18
|
+
* "the one zip asset".
|
|
19
|
+
*/
|
|
20
|
+
export async function downloadLatestFullZip() {
|
|
21
|
+
const releases = await got(`https://api.github.com/repos/${REPO}/releases`, {
|
|
22
|
+
headers: { accept: 'application/vnd.github+json', 'user-agent': 'loopress-cli' },
|
|
23
|
+
searchParams: { 'per_page': 30 },
|
|
24
|
+
}).json();
|
|
25
|
+
const pluginRelease = releases.find((release) => RELEASE_TAG_PATTERN.test(release.tag_name));
|
|
26
|
+
if (!pluginRelease) {
|
|
27
|
+
throw new Error(`No "wordpress-plugin@*" release found in ${REPO}.`);
|
|
28
|
+
}
|
|
29
|
+
const asset = pluginRelease.assets.find((candidate) => candidate.name === ASSET_NAME);
|
|
30
|
+
if (!asset) {
|
|
31
|
+
throw new Error(`Release "${pluginRelease.tag_name}" has no "${ASSET_NAME}" asset.`);
|
|
32
|
+
}
|
|
33
|
+
const dir = await mkdtemp(join(tmpdir(), 'lps-loopress-full-'));
|
|
34
|
+
const zipPath = join(dir, ASSET_NAME);
|
|
35
|
+
await pipeline(got.stream(asset.browser_download_url), createWriteStream(zipPath));
|
|
36
|
+
return zipPath;
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isInteractive(): boolean;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Whether prompts can be shown at all: a real terminal on both ends, and not a CI runner.
|
|
2
|
+
// CI=1 with a pseudo-TTY still counts as non-interactive, which is what CI users expect.
|
|
3
|
+
// Every command that prompts routes its decision through this so the CLI never hangs on a
|
|
4
|
+
// question nobody can answer.
|
|
5
|
+
export function isInteractive() {
|
|
6
|
+
return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && !process.env.CI;
|
|
7
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { extname, join } from 'node:path';
|
|
3
|
+
// Loads every `<extension>` file of a flat directory through `parse`. One file is read in
|
|
4
|
+
// isolation: a corrupted or hand-broken file (unreadable, bad JSON, failed validation) must
|
|
5
|
+
// only skip that file, never abort loading the rest of the directory, so `parse` throwing is
|
|
6
|
+
// reported through `onSkip` and the loop moves on. ENOENT on the directory itself means
|
|
7
|
+
// "nothing to load", not an error.
|
|
8
|
+
export async function loadFiles(dir, options) {
|
|
9
|
+
let entries;
|
|
10
|
+
try {
|
|
11
|
+
entries = await readdir(dir);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code === 'ENOENT')
|
|
15
|
+
return [];
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
const items = [];
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (extname(entry) !== options.extension)
|
|
21
|
+
continue;
|
|
22
|
+
const filePath = join(dir, entry);
|
|
23
|
+
try {
|
|
24
|
+
items.push(options.parse(await readFile(filePath, 'utf8'), filePath));
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
options.onSkip(`Skipping "${filePath}": ${error.message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return items;
|
|
31
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { WpClient } from './wp-client.js';
|
|
2
|
+
/**
|
|
3
|
+
* Checks `wp/v2/plugins` directly rather than through `parseInstalledPlugins`
|
|
4
|
+
* (`src/utils/plugins.ts`), which deliberately filters Loopress's own plugin slugs out of its
|
|
5
|
+
* result so `plugin push` never tries to manage itself. This is the one place that needs to
|
|
6
|
+
* see Loopress Full itself.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isLoopressFullActive(wp: WpClient): Promise<boolean>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const FULL_PLUGIN_PREFIX = 'loopress-full/';
|
|
2
|
+
/**
|
|
3
|
+
* Checks `wp/v2/plugins` directly rather than through `parseInstalledPlugins`
|
|
4
|
+
* (`src/utils/plugins.ts`), which deliberately filters Loopress's own plugin slugs out of its
|
|
5
|
+
* result so `plugin push` never tries to manage itself. This is the one place that needs to
|
|
6
|
+
* see Loopress Full itself.
|
|
7
|
+
*/
|
|
8
|
+
export async function isLoopressFullActive(wp) {
|
|
9
|
+
const plugins = await wp.get('wp/v2/plugins');
|
|
10
|
+
return plugins.some((plugin) => plugin.plugin.startsWith(FULL_PLUGIN_PREFIX) && plugin.status === 'active');
|
|
11
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { LoopressCommand } from './base.js';
|
|
2
2
|
export declare abstract class PushCommand extends LoopressCommand {
|
|
3
|
+
protected failedCount: number;
|
|
4
|
+
private refusedByGuard;
|
|
3
5
|
catch(err: Error): Promise<void>;
|
|
6
|
+
protected guardProductionPush(): Promise<void>;
|
|
7
|
+
init(): Promise<void>;
|
|
4
8
|
protected recordDeployment(status: 'failure' | 'success'): Promise<void>;
|
|
5
9
|
protected recordSuccess(): Promise<void>;
|
|
10
|
+
protected reportTaskFailure(message: string, error: unknown, task?: {
|
|
11
|
+
output: string;
|
|
12
|
+
}): never;
|
|
6
13
|
}
|
package/dist/lib/push-command.js
CHANGED
|
@@ -1,14 +1,39 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
1
2
|
import got from 'got';
|
|
2
3
|
import { authManager } from '../config/auth.manager.js';
|
|
4
|
+
import { API_URL } from './api-client.js';
|
|
3
5
|
import { LoopressCommand } from './base.js';
|
|
4
|
-
|
|
6
|
+
import { isInteractive } from './interactive.js';
|
|
5
7
|
export class PushCommand extends LoopressCommand {
|
|
8
|
+
failedCount = 0;
|
|
9
|
+
// Refusals of the production guard never reach the site, so they must not pollute the
|
|
10
|
+
// deployment history with failure records.
|
|
11
|
+
refusedByGuard = false;
|
|
6
12
|
async catch(err) {
|
|
7
|
-
if (!this.dryRun && this.siteConfig) {
|
|
13
|
+
if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
|
|
8
14
|
await this.recordDeployment('failure');
|
|
9
15
|
}
|
|
10
16
|
return super.catch(err);
|
|
11
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
|
+
async guardProductionPush() {
|
|
21
|
+
if (this.siteConfig.name.toLowerCase() !== 'production' || this.dryRun || this.yes)
|
|
22
|
+
return;
|
|
23
|
+
if (!isInteractive()) {
|
|
24
|
+
this.refusedByGuard = true;
|
|
25
|
+
this.error('Target environment is "production". Pass --yes to confirm the push in a non-interactive run.');
|
|
26
|
+
}
|
|
27
|
+
const proceed = await confirm({ default: true, message: `Push to production (${this.siteConfig.url})?` });
|
|
28
|
+
if (!proceed) {
|
|
29
|
+
this.refusedByGuard = true;
|
|
30
|
+
this.error('Aborted.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async init() {
|
|
34
|
+
await super.init();
|
|
35
|
+
await this.guardProductionPush();
|
|
36
|
+
}
|
|
12
37
|
async recordDeployment(status) {
|
|
13
38
|
const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token ?? null;
|
|
14
39
|
if (!token)
|
|
@@ -28,4 +53,17 @@ export class PushCommand extends LoopressCommand {
|
|
|
28
53
|
if (!this.dryRun)
|
|
29
54
|
await this.recordDeployment('success');
|
|
30
55
|
}
|
|
56
|
+
// Reports one failed Listr task: status lines go through `task.output` when running inside a
|
|
57
|
+
// Listr renderer (this.warn would race with the repaint), and fall back to `this.warn` when
|
|
58
|
+
// called without a task (e.g. directly in tests). Rethrowing is what lets Listr mark the task
|
|
59
|
+
// failed (red cross) instead of completed; `exitOnError: false` on the list still lets sibling
|
|
60
|
+
// tasks run, and the accumulated `failedCount` drives the command's final "N failed" error.
|
|
61
|
+
reportTaskFailure(message, error, task) {
|
|
62
|
+
if (task)
|
|
63
|
+
task.output = message;
|
|
64
|
+
else
|
|
65
|
+
this.warn(` ${message}`);
|
|
66
|
+
this.failedCount++;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
31
69
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { EnvironmentConfig } from '../types/config.js';
|
|
2
|
+
export declare const APP_PASSWORD_MAX_AGE_MS: number;
|
|
3
|
+
export declare function isAppPasswordStale(addedAt: string): boolean;
|
|
4
|
+
export declare function rotateAppPassword(env: EnvironmentConfig & {
|
|
5
|
+
token: string;
|
|
6
|
+
}): Promise<EnvironmentConfig>;
|