@loopress/cli 0.24.0 → 0.24.1
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 +158 -81
- package/dist/commands/api/rm.d.ts +2 -0
- package/dist/commands/api/rm.js +2 -0
- package/dist/commands/composer/push.d.ts +3 -0
- package/dist/commands/composer/push.js +24 -7
- package/dist/commands/form/push.d.ts +2 -0
- package/dist/commands/form/push.js +11 -4
- package/dist/commands/hook/rm.d.ts +2 -0
- package/dist/commands/hook/rm.js +2 -0
- package/dist/commands/login.js +3 -2
- package/dist/commands/seo/push.d.ts +2 -0
- package/dist/commands/seo/push.js +16 -4
- package/dist/hooks/finally.js +4 -1
- package/dist/lib/local-callback-server.d.ts +14 -1
- package/dist/lib/local-callback-server.js +40 -1
- package/dist/lib/php-files-command.d.ts +16 -3
- package/dist/lib/php-files-command.js +136 -14
- package/dist/lib/sentry.d.ts +3 -0
- package/dist/lib/sentry.js +30 -0
- package/dist/lib/wp-authorize-flow.d.ts +4 -0
- package/dist/lib/wp-authorize-flow.js +10 -8
- package/dist/utils/option-format.d.ts +1 -1
- package/dist/utils/option-format.js +22 -4
- package/dist/utils/plugin-sync.d.ts +6 -0
- package/oclif.manifest.json +341 -161
- package/package.json +2 -2
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { PushCommand } from '../../lib/push-command.js';
|
|
2
|
+
import { type LockDriftEntry } from '../../utils/plugin-sync.js';
|
|
2
3
|
type PushResult = {
|
|
3
4
|
hasLock: boolean;
|
|
5
|
+
lockDrift: LockDriftEntry[];
|
|
4
6
|
packageCount: number;
|
|
5
7
|
status: 'dry-run' | 'success';
|
|
6
8
|
};
|
|
@@ -14,5 +16,6 @@ export default class ComposerPush extends PushCommand {
|
|
|
14
16
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
17
|
};
|
|
16
18
|
run(): Promise<PushResult>;
|
|
19
|
+
private reportLockDrift;
|
|
17
20
|
}
|
|
18
21
|
export {};
|
|
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { PushCommand } from '../../lib/push-command.js';
|
|
6
6
|
import { isTimeoutError } from '../../lib/wp-client.js';
|
|
7
|
-
import { parseCollisions, SYNC_TIMEOUT_MS } from '../../utils/plugin-sync.js';
|
|
7
|
+
import { parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
|
|
8
8
|
// Split a composer.json `require` map into the three intent namespaces the sync endpoint
|
|
9
9
|
// understands. `composer/installers` is owned by the server scaffold, never sent.
|
|
10
10
|
function toIntent(require) {
|
|
@@ -25,7 +25,7 @@ function toIntent(require) {
|
|
|
25
25
|
return intent;
|
|
26
26
|
}
|
|
27
27
|
export default class ComposerPush extends PushCommand {
|
|
28
|
-
static description = 'Push composer.json
|
|
28
|
+
static description = 'Push composer.json to WordPress and run Composer to resolve and install dependencies';
|
|
29
29
|
static enableJsonFlag = true;
|
|
30
30
|
static examples = ['$ lps composer push', '$ lps composer push --dry-run'];
|
|
31
31
|
static flags = {
|
|
@@ -47,14 +47,16 @@ export default class ComposerPush extends PushCommand {
|
|
|
47
47
|
const packageCount = Object.keys(require).length;
|
|
48
48
|
const intent = toIntent(require);
|
|
49
49
|
const hasLock = existsSync(composerLockPath);
|
|
50
|
+
// The server resolves composer.json itself; it never installs from this lock (a crafted
|
|
51
|
+
// lock could point package downloads at arbitrary hosts). It is sent only so the server
|
|
52
|
+
// can report which pinned versions its own resolution moved. `lps composer pull` brings
|
|
53
|
+
// the resolved lock back.
|
|
50
54
|
const lock = hasLock ? await readFile(composerLockPath, 'utf8') : null;
|
|
51
55
|
this.log(`Pushing composer.json (${packageCount} ${packageCount === 1 ? 'package' : 'packages'}) to ${url}`);
|
|
52
56
|
if (lock)
|
|
53
|
-
this.log(' + composer.lock
|
|
54
|
-
else
|
|
55
|
-
this.warn('No composer.lock found. The server will resolve versions freely.');
|
|
57
|
+
this.log(' + composer.lock sent for drift comparison (the server resolves versions from composer.json)');
|
|
56
58
|
if (this.dryRun)
|
|
57
|
-
return { hasLock, packageCount, status: 'dry-run' };
|
|
59
|
+
return { hasLock, lockDrift: [], packageCount, status: 'dry-run' };
|
|
58
60
|
this.log('Running Composer on the server, this can take a few minutes...');
|
|
59
61
|
let response;
|
|
60
62
|
try {
|
|
@@ -72,7 +74,22 @@ export default class ComposerPush extends PushCommand {
|
|
|
72
74
|
if (response.output.trim())
|
|
73
75
|
this.log(response.output.trim());
|
|
74
76
|
this.log('Composer run completed on the server.');
|
|
77
|
+
const lockDrift = response.lockDrift ?? [];
|
|
78
|
+
this.reportLockDrift(lockDrift);
|
|
75
79
|
await this.recordSuccess();
|
|
76
|
-
return { hasLock, packageCount, status: 'success' };
|
|
80
|
+
return { hasLock, lockDrift, packageCount, status: 'success' };
|
|
81
|
+
}
|
|
82
|
+
// The server resolves composer.json from scratch and never installs from the uploaded lock,
|
|
83
|
+
// so a project that committed a lock can see versions move. Spell out exactly what changed
|
|
84
|
+
// and point at `lps composer pull` to bring the resolved lock back locally.
|
|
85
|
+
reportLockDrift(lockDrift) {
|
|
86
|
+
if (lockDrift.length === 0)
|
|
87
|
+
return;
|
|
88
|
+
this.log('');
|
|
89
|
+
this.log(`The server resolved ${lockDrift.length} ${lockDrift.length === 1 ? 'package' : 'packages'} to a different version than your local composer.lock:`);
|
|
90
|
+
for (const { from, name, to } of lockDrift) {
|
|
91
|
+
this.log(` ${name}: ${from ?? '(absent)'} -> ${to ?? '(removed)'}`);
|
|
92
|
+
}
|
|
93
|
+
this.log('Run `lps composer pull` to update your local composer.json and composer.lock.');
|
|
77
94
|
}
|
|
78
95
|
}
|
|
@@ -6,9 +6,11 @@ export default class Push extends PushCommand {
|
|
|
6
6
|
static description: string;
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
|
+
'allow-notifications': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
10
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
11
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
12
|
};
|
|
13
|
+
private allowNotifications;
|
|
12
14
|
run(): Promise<void>;
|
|
13
15
|
private ensureCanonicalFilename;
|
|
14
16
|
private loadFiles;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Args } from '@oclif/core';
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { readFile, rename } from 'node:fs/promises';
|
|
3
3
|
import { dirname, extname, join } from 'node:path';
|
|
4
4
|
import { PushCommand } from '../../lib/push-command.js';
|
|
@@ -12,15 +12,21 @@ export default class Push extends PushCommand {
|
|
|
12
12
|
path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
|
|
13
13
|
};
|
|
14
14
|
static description = 'Push forms to WordPress. Local files created or updated remotely are renamed on disk to the `<id>-<slug>.json` convention.';
|
|
15
|
-
static examples = ['$ lps form push'];
|
|
15
|
+
static examples = ['$ lps form push', '$ lps form push --allow-notifications'];
|
|
16
16
|
static flags = {
|
|
17
17
|
...PushCommand.dryRunFlag,
|
|
18
18
|
...PushCommand.yesFlag,
|
|
19
|
+
'allow-notifications': Flags.boolean({
|
|
20
|
+
default: false,
|
|
21
|
+
description: "Also push each form's notification and confirmation settings (recipients, sender, messages). Off by default: the server keeps its own so a stray push can't redirect submissions.",
|
|
22
|
+
}),
|
|
19
23
|
};
|
|
24
|
+
allowNotifications = false;
|
|
20
25
|
async run() {
|
|
21
|
-
const { args } = await this.parse(Push);
|
|
26
|
+
const { args, flags } = await this.parse(Push);
|
|
22
27
|
const { url } = this.siteConfig;
|
|
23
28
|
const path = this.resolveFormPath(args.path);
|
|
29
|
+
this.allowNotifications = flags['allow-notifications'];
|
|
24
30
|
this.log(`Pushing forms to ${url}`);
|
|
25
31
|
this.log(`Forms path: ${path}`);
|
|
26
32
|
const files = await this.loadFiles(path);
|
|
@@ -78,9 +84,10 @@ export default class Push extends PushCommand {
|
|
|
78
84
|
}
|
|
79
85
|
try {
|
|
80
86
|
const id = getFormId(data);
|
|
87
|
+
const payload = this.allowNotifications ? { ...data, allowNotifications: true } : data;
|
|
81
88
|
const { body, created } = await putOrCreate(this.wp, {
|
|
82
89
|
id,
|
|
83
|
-
payload
|
|
90
|
+
payload,
|
|
84
91
|
postEndpoint: FORM_ENDPOINT,
|
|
85
92
|
putEndpoint: (formId) => `${FORM_ENDPOINT}/${formId}`,
|
|
86
93
|
});
|
package/dist/commands/login.js
CHANGED
|
@@ -13,8 +13,9 @@ export default class Login extends Command {
|
|
|
13
13
|
}
|
|
14
14
|
async waitForCallback() {
|
|
15
15
|
return waitForLocalCallback({
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
allowedOrigins: [CONSOLE_URL],
|
|
17
|
+
buildUrl(callbackBaseUrl, state) {
|
|
18
|
+
const callbackUrl = encodeURIComponent(`${callbackBaseUrl}/callback?state=${state}`);
|
|
18
19
|
return `${CONSOLE_URL}/cli-auth?callbackUrl=${callbackUrl}`;
|
|
19
20
|
},
|
|
20
21
|
handleRequest(url, { resolveWithPage, respondBadRequest }) {
|
|
@@ -6,9 +6,11 @@ export default class Push extends PushCommand {
|
|
|
6
6
|
static description: string;
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
|
+
'allow-external-redirects': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
10
|
yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
11
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
12
|
};
|
|
13
|
+
private allowExternalRedirects;
|
|
12
14
|
run(): Promise<void>;
|
|
13
15
|
private jsonFilesIn;
|
|
14
16
|
private pushPostMeta;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Args } from '@oclif/core';
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, extname, join } from 'node:path';
|
|
4
4
|
import { PushCommand } from '../../lib/push-command.js';
|
|
@@ -11,15 +11,21 @@ export default class Push extends PushCommand {
|
|
|
11
11
|
path: Args.string({ description: 'Path to SEO directory (overrides project config)' }),
|
|
12
12
|
};
|
|
13
13
|
static description = 'Push SEO settings, post meta, and redirects to WordPress. Local redirect files created remotely are renamed on disk to the `<id>-<slug>` convention. Fails clearly per file if the active SEO plugin does not support redirects.';
|
|
14
|
-
static examples = ['$ lps seo push'];
|
|
14
|
+
static examples = ['$ lps seo push', '$ lps seo push --allow-external-redirects'];
|
|
15
15
|
static flags = {
|
|
16
16
|
...PushCommand.dryRunFlag,
|
|
17
17
|
...PushCommand.yesFlag,
|
|
18
|
+
'allow-external-redirects': Flags.boolean({
|
|
19
|
+
default: false,
|
|
20
|
+
description: 'Allow pushing a redirect whose target points off this site. Off by default: the server rejects an off-site `urlTo` so a stray push can\'t 301 traffic away.',
|
|
21
|
+
}),
|
|
18
22
|
};
|
|
23
|
+
allowExternalRedirects = false;
|
|
19
24
|
async run() {
|
|
20
|
-
const { args } = await this.parse(Push);
|
|
25
|
+
const { args, flags } = await this.parse(Push);
|
|
21
26
|
const { url } = this.siteConfig;
|
|
22
27
|
const path = this.resolveSeoPath(args.path);
|
|
28
|
+
this.allowExternalRedirects = flags['allow-external-redirects'];
|
|
23
29
|
this.log(`Pushing SEO configuration to ${url}`);
|
|
24
30
|
this.log(`SEO path: ${path}`);
|
|
25
31
|
await this.pushSettings(path);
|
|
@@ -74,7 +80,13 @@ export default class Push extends PushCommand {
|
|
|
74
80
|
}
|
|
75
81
|
try {
|
|
76
82
|
const redirect = JSON.parse(await readFile(filePath, 'utf8'));
|
|
77
|
-
const payload = {
|
|
83
|
+
const payload = {
|
|
84
|
+
headerCode: redirect.headerCode,
|
|
85
|
+
sources: redirect.sources,
|
|
86
|
+
status: redirect.status,
|
|
87
|
+
urlTo: redirect.urlTo,
|
|
88
|
+
...(this.allowExternalRedirects && { allowExternal: true }),
|
|
89
|
+
};
|
|
78
90
|
// The id recorded locally may not exist on this site (e.g. a fresh install): putOrCreate
|
|
79
91
|
// falls back to POST instead of failing, adopting whatever id the site assigns.
|
|
80
92
|
const { body, created } = await putOrCreate(this.wp, {
|
package/dist/hooks/finally.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isTelemetryDisabled, redactArgv, resolveEnvironment, runtimeContext, SENTRY_DSN, } from '../lib/sentry.js';
|
|
1
|
+
import { isTelemetryDisabled, redactArgv, resolveEnvironment, runtimeContext, scrubEvent, SENTRY_DSN, } from '../lib/sentry.js';
|
|
2
2
|
// oclif has no `command_error` hook (checked @oclif/core@4.11.11's hooks.d.ts). `finally`
|
|
3
3
|
// is the closest equivalent: it always runs at the end of the CLI lifecycle and carries
|
|
4
4
|
// the error, if any, so it's where we report crashes before the process exits.
|
|
@@ -12,6 +12,9 @@ const hook = async function (options) {
|
|
|
12
12
|
try {
|
|
13
13
|
const Sentry = await import('@sentry/node');
|
|
14
14
|
Sentry.init({
|
|
15
|
+
// Strip the site URL and the raw server response body out of every event. A WordPress
|
|
16
|
+
// request failure otherwise carries both in its message (F26).
|
|
17
|
+
beforeSend: scrubEvent,
|
|
15
18
|
dsn: SENTRY_DSN,
|
|
16
19
|
environment: resolveEnvironment(),
|
|
17
20
|
release: this.config.version,
|
|
@@ -7,9 +7,22 @@ export type CallbackHelpers<T> = {
|
|
|
7
7
|
/**
|
|
8
8
|
* Sends the user to a URL in their browser and catches the resulting redirect on a short-lived
|
|
9
9
|
* local server; this factors out the server setup, timeout, and browser-opening boilerplate.
|
|
10
|
+
*
|
|
11
|
+
* The loopback server is unauthenticated, so any local process or web page open during the wait
|
|
12
|
+
* could otherwise POST forged credentials to it (RFC 8252 section 8.9). Two guards close that:
|
|
13
|
+
*
|
|
14
|
+
* - a 32-byte `state` generated here, threaded into the authorize URL by `buildUrl`, and required
|
|
15
|
+
* back (constant-time compare) on any request that carries credentials;
|
|
16
|
+
* - an `Origin` allowlist: a browser sends `Origin` on the relay's cross-site form POST, so a
|
|
17
|
+
* stray page's POST (carrying its own origin) is rejected. Top-level navigations send no
|
|
18
|
+
* `Origin` and are allowed, which is why `state` is the primary control.
|
|
19
|
+
*
|
|
20
|
+
* The first request that looks like a callback (valid or not) shuts the server down, so a
|
|
21
|
+
* failed guess gets no second try within the window.
|
|
10
22
|
*/
|
|
11
23
|
export declare function waitForLocalCallback<T>(options: {
|
|
12
|
-
|
|
24
|
+
allowedOrigins: string[];
|
|
25
|
+
buildUrl: (callbackBaseUrl: string, state: string) => string;
|
|
13
26
|
handleRequest: (url: URL, helpers: CallbackHelpers<T>) => void;
|
|
14
27
|
log: (message: string) => void;
|
|
15
28
|
openingMessage: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
3
|
import { createServer } from 'node:http';
|
|
3
4
|
import { openBrowser } from './open-browser.js';
|
|
4
5
|
async function readBody(req) {
|
|
@@ -10,12 +11,31 @@ async function readBody(req) {
|
|
|
10
11
|
function parseFormData(body) {
|
|
11
12
|
return Object.fromEntries(new URLSearchParams(body));
|
|
12
13
|
}
|
|
14
|
+
/** Constant-time compare that also tolerates a length mismatch without throwing. */
|
|
15
|
+
function safeEqual(a, b) {
|
|
16
|
+
const bufferA = Buffer.from(a);
|
|
17
|
+
const bufferB = Buffer.from(b);
|
|
18
|
+
return bufferA.length === bufferB.length && timingSafeEqual(bufferA, bufferB);
|
|
19
|
+
}
|
|
13
20
|
/**
|
|
14
21
|
* Sends the user to a URL in their browser and catches the resulting redirect on a short-lived
|
|
15
22
|
* local server; this factors out the server setup, timeout, and browser-opening boilerplate.
|
|
23
|
+
*
|
|
24
|
+
* The loopback server is unauthenticated, so any local process or web page open during the wait
|
|
25
|
+
* could otherwise POST forged credentials to it (RFC 8252 section 8.9). Two guards close that:
|
|
26
|
+
*
|
|
27
|
+
* - a 32-byte `state` generated here, threaded into the authorize URL by `buildUrl`, and required
|
|
28
|
+
* back (constant-time compare) on any request that carries credentials;
|
|
29
|
+
* - an `Origin` allowlist: a browser sends `Origin` on the relay's cross-site form POST, so a
|
|
30
|
+
* stray page's POST (carrying its own origin) is rejected. Top-level navigations send no
|
|
31
|
+
* `Origin` and are allowed, which is why `state` is the primary control.
|
|
32
|
+
*
|
|
33
|
+
* The first request that looks like a callback (valid or not) shuts the server down, so a
|
|
34
|
+
* failed guess gets no second try within the window.
|
|
16
35
|
*/
|
|
17
36
|
export async function waitForLocalCallback(options) {
|
|
18
37
|
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
|
|
38
|
+
const state = randomBytes(32).toString('hex');
|
|
19
39
|
return new Promise((resolve, reject) => {
|
|
20
40
|
function finish(res, page) {
|
|
21
41
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
@@ -23,10 +43,29 @@ export async function waitForLocalCallback(options) {
|
|
|
23
43
|
clearTimeout(timer);
|
|
24
44
|
server.close();
|
|
25
45
|
}
|
|
46
|
+
function rejectRequest(res, message) {
|
|
47
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
48
|
+
res.end(message);
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
server.close();
|
|
51
|
+
reject(new Error(message));
|
|
52
|
+
}
|
|
26
53
|
async function handleIncoming(req, res) {
|
|
27
54
|
try {
|
|
28
55
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
29
56
|
const body = req.method === 'POST' ? parseFormData(await readBody(req)) : {};
|
|
57
|
+
const looksLikeCallback = url.searchParams.has('state') || Object.keys(body).length > 0;
|
|
58
|
+
if (looksLikeCallback) {
|
|
59
|
+
const { origin } = req.headers;
|
|
60
|
+
if (origin !== undefined && !options.allowedOrigins.includes(origin)) {
|
|
61
|
+
rejectRequest(res, 'Rejected a cross-origin request to the login callback server.');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!safeEqual(url.searchParams.get('state') ?? '', state)) {
|
|
65
|
+
rejectRequest(res, 'Rejected a login callback with a missing or invalid state value.');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
30
69
|
options.handleRequest(url, {
|
|
31
70
|
rejectWithPage(page, error) {
|
|
32
71
|
finish(res, page);
|
|
@@ -61,7 +100,7 @@ export async function waitForLocalCallback(options) {
|
|
|
61
100
|
}, timeoutMs);
|
|
62
101
|
server.listen(0, '127.0.0.1', () => {
|
|
63
102
|
const { port } = server.address();
|
|
64
|
-
const targetUrl = options.buildUrl(`http://localhost:${port}
|
|
103
|
+
const targetUrl = options.buildUrl(`http://localhost:${port}`, state);
|
|
65
104
|
options.log(options.openingMessage);
|
|
66
105
|
options.log(`\nIf it doesn't open automatically, visit:\n${targetUrl}\n`);
|
|
67
106
|
openBrowser(targetUrl);
|
|
@@ -22,12 +22,16 @@ export type PhpFilesResource = {
|
|
|
22
22
|
pathNoun: string;
|
|
23
23
|
pullDescription: string;
|
|
24
24
|
pushDescription: string;
|
|
25
|
+
rmDescription: string;
|
|
25
26
|
};
|
|
26
|
-
type
|
|
27
|
-
content
|
|
27
|
+
type RemotePhpFile = {
|
|
28
|
+
content?: string;
|
|
29
|
+
error?: string;
|
|
28
30
|
filename: string;
|
|
31
|
+
public?: boolean;
|
|
29
32
|
};
|
|
30
33
|
type PushResult = {
|
|
34
|
+
pruned: string[];
|
|
31
35
|
pushed: string[];
|
|
32
36
|
status: 'dry-run' | 'success';
|
|
33
37
|
};
|
|
@@ -36,6 +40,11 @@ type PullResult = {
|
|
|
36
40
|
pulled: string[];
|
|
37
41
|
status: 'dry-run' | 'success';
|
|
38
42
|
};
|
|
43
|
+
type RmResult = {
|
|
44
|
+
filename: string;
|
|
45
|
+
removed: boolean;
|
|
46
|
+
status: 'aborted' | 'dry-run' | 'success';
|
|
47
|
+
};
|
|
39
48
|
export type CommandClass<TInstance> = new (argv: string[], config: Config) => TInstance;
|
|
40
49
|
export type PushFilesCommand = Omit<PushCommand, 'run'> & {
|
|
41
50
|
run(): Promise<PushResult>;
|
|
@@ -44,11 +53,15 @@ export type PullFilesCommand = Omit<LoopressCommand, 'run'> & {
|
|
|
44
53
|
run(): Promise<PullResult>;
|
|
45
54
|
};
|
|
46
55
|
export type ListFilesCommand = Omit<LoopressCommand, 'run'> & {
|
|
47
|
-
run(): Promise<
|
|
56
|
+
run(): Promise<RemotePhpFile[]>;
|
|
57
|
+
};
|
|
58
|
+
export type RmFilesCommand = Omit<LoopressCommand, 'run'> & {
|
|
59
|
+
run(): Promise<RmResult>;
|
|
48
60
|
};
|
|
49
61
|
export declare function resourcePushCommand(spec: PhpFilesResource): CommandClass<PushFilesCommand>;
|
|
50
62
|
export declare function resourcePullCommand(spec: PhpFilesResource): CommandClass<PullFilesCommand>;
|
|
51
63
|
export declare function resourceListCommand(spec: PhpFilesResource): CommandClass<ListFilesCommand>;
|
|
64
|
+
export declare function resourceRmCommand(spec: PhpFilesResource): CommandClass<RmFilesCommand>;
|
|
52
65
|
export declare const API_FILES_RESOURCE: PhpFilesResource;
|
|
53
66
|
export declare const HOOK_FILES_RESOURCE: PhpFilesResource;
|
|
54
67
|
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
3
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { dirname, join, relative, sep } from 'node:path';
|
|
4
5
|
import { authManager } from '../config/auth.manager.js';
|
|
@@ -7,8 +8,10 @@ import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dir
|
|
|
7
8
|
import { ApiClient } from './api-client.js';
|
|
8
9
|
import { LoopressCommand } from './base.js';
|
|
9
10
|
import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
|
|
11
|
+
import { isInteractive } from './interactive.js';
|
|
10
12
|
import { loadFiles as loadDirectoryFiles } from './load-files.js';
|
|
11
13
|
import { PushCommand } from './push-command.js';
|
|
14
|
+
import { isApplicative404 } from './wp-client.js';
|
|
12
15
|
// Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
|
|
13
16
|
// an absent declare(strict_types=1); and one that appears more than once (it needs a single
|
|
14
17
|
// unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
|
|
@@ -35,10 +38,21 @@ export function resourcePushCommand(spec) {
|
|
|
35
38
|
static args = pathArg(spec.pathNoun);
|
|
36
39
|
static description = spec.pushDescription;
|
|
37
40
|
static enableJsonFlag = true;
|
|
38
|
-
static examples = [
|
|
39
|
-
|
|
41
|
+
static examples = [
|
|
42
|
+
`$ lps ${spec.cliName} push`,
|
|
43
|
+
`$ lps ${spec.cliName} push --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`,
|
|
44
|
+
`$ lps ${spec.cliName} push --prune`,
|
|
45
|
+
];
|
|
46
|
+
static flags = {
|
|
47
|
+
...PushCommand.dryRunFlag,
|
|
48
|
+
...PushCommand.yesFlag,
|
|
49
|
+
prune: Flags.boolean({
|
|
50
|
+
default: false,
|
|
51
|
+
description: `Delete server-side ${spec.noun}s not present locally after pushing`,
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
40
54
|
async run() {
|
|
41
|
-
const { args } = await this.parse(ResourcePush);
|
|
55
|
+
const { args, flags } = await this.parse(ResourcePush);
|
|
42
56
|
const { url } = this.siteConfig;
|
|
43
57
|
const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
|
|
44
58
|
this.log(`Pushing ${spec.label} to ${url}`);
|
|
@@ -46,23 +60,66 @@ export function resourcePushCommand(spec) {
|
|
|
46
60
|
const files = await this.loadFiles(path);
|
|
47
61
|
this.log(`Found ${pluralize(files.length, spec.noun)} to push`);
|
|
48
62
|
const pushed = [];
|
|
63
|
+
const publicRoutes = [];
|
|
49
64
|
await this.runPushTasks(files, (file) => file.filename, async (file, task) => {
|
|
50
|
-
await this.pushFile(file, task);
|
|
65
|
+
const result = await this.pushFile(file, task);
|
|
51
66
|
pushed.push(file.filename);
|
|
67
|
+
if (result?.public)
|
|
68
|
+
publicRoutes.push(file.filename);
|
|
52
69
|
});
|
|
53
70
|
if (this.failedCount > 0) {
|
|
54
71
|
this.error(`${pluralize(this.failedCount, spec.noun)} failed to push.`);
|
|
55
72
|
}
|
|
73
|
+
for (const route of publicRoutes) {
|
|
74
|
+
this.warn(`${route}: PUBLIC route, no authentication required. Anyone on the internet can call it. See https://loopress.dev/api/routes/#authentication-and-permissions`);
|
|
75
|
+
}
|
|
76
|
+
const pruned = flags.prune ? await this.prune(new Set(files.map((file) => file.filename))) : [];
|
|
56
77
|
if (this.dryRun)
|
|
57
|
-
return { pushed, status: 'dry-run' };
|
|
78
|
+
return { pruned, pushed, status: 'dry-run' };
|
|
58
79
|
await this.recordSuccess();
|
|
59
80
|
await spec.afterPush?.({ filenames: pushed, siteConfig: this.siteConfig });
|
|
60
81
|
this.log(`All ${spec.label} pushed.`);
|
|
61
|
-
return { pushed, status: 'success' };
|
|
82
|
+
return { pruned, pushed, status: 'success' };
|
|
62
83
|
}
|
|
63
84
|
async loadFiles(path) {
|
|
64
85
|
return loadPhpFiles(this, path);
|
|
65
86
|
}
|
|
87
|
+
// Mirror of `pull`'s local orphan cleanup, aimed at the server: any file on WordPress with
|
|
88
|
+
// no local counterpart is deleted. Opt-in (`--prune`) because it removes code from a live
|
|
89
|
+
// site. In a non-TTY without `--yes` it refuses rather than prunes: a stray local path in
|
|
90
|
+
// CI must not silently wipe production routes, and unlike `pull`'s local deletions these
|
|
91
|
+
// are not recoverable from the repo.
|
|
92
|
+
async prune(localFilenames) {
|
|
93
|
+
const remote = await this.wp.get(spec.endpoint);
|
|
94
|
+
const orphans = remote.map((file) => file.filename).filter((name) => !localFilenames.has(name));
|
|
95
|
+
if (orphans.length === 0)
|
|
96
|
+
return [];
|
|
97
|
+
const summary = `${pluralize(orphans.length, spec.noun)} on ${this.siteConfig.url} not present locally: ${orphans.join(', ')}`;
|
|
98
|
+
if (this.dryRun) {
|
|
99
|
+
this.log(`[dry-run] Would prune ${summary}`);
|
|
100
|
+
return orphans;
|
|
101
|
+
}
|
|
102
|
+
if (!this.yes) {
|
|
103
|
+
if (!isInteractive()) {
|
|
104
|
+
const count = pluralize(orphans.length, `server-side ${spec.noun}`);
|
|
105
|
+
this.error(`--prune would delete ${count} but stdin is not a TTY. Re-run with --yes to confirm, or without --prune.`);
|
|
106
|
+
}
|
|
107
|
+
const ok = await confirm({ default: false, message: `Prune ${summary}?` });
|
|
108
|
+
if (!ok) {
|
|
109
|
+
this.log('Kept the server-side files, nothing pruned.');
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Sequential, like runPushTasks: keeps the "Pruned: …" lines ordered and the WordPress
|
|
114
|
+
// writes serial.
|
|
115
|
+
const deleted = [];
|
|
116
|
+
for (const filename of orphans) {
|
|
117
|
+
await this.wp.delete(`${spec.endpoint}?filename=${encodeURIComponent(filename)}`);
|
|
118
|
+
this.log(`Pruned: ${filename}`);
|
|
119
|
+
deleted.push(filename);
|
|
120
|
+
}
|
|
121
|
+
return deleted;
|
|
122
|
+
}
|
|
66
123
|
async pushFile(file, task) {
|
|
67
124
|
if (!spec.filenamePattern.test(file.filename)) {
|
|
68
125
|
const message = `Invalid filename "${file.filename}": ${spec.invalidFilenameHint}`;
|
|
@@ -77,7 +134,7 @@ export function resourcePushCommand(spec) {
|
|
|
77
134
|
if (this.dryRun) {
|
|
78
135
|
if (task)
|
|
79
136
|
task.output = `[dry-run] Would push: ${file.filename}`;
|
|
80
|
-
return;
|
|
137
|
+
return undefined;
|
|
81
138
|
}
|
|
82
139
|
try {
|
|
83
140
|
const result = await this.wp.put(spec.endpoint, {
|
|
@@ -90,6 +147,7 @@ export function resourcePushCommand(spec) {
|
|
|
90
147
|
? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
|
|
91
148
|
: `Pushed: ${file.filename}`;
|
|
92
149
|
}
|
|
150
|
+
return result;
|
|
93
151
|
}
|
|
94
152
|
catch (error) {
|
|
95
153
|
this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
|
|
@@ -112,19 +170,28 @@ export function resourcePullCommand(spec) {
|
|
|
112
170
|
this.log(`Pulling ${spec.label} from ${url}`);
|
|
113
171
|
this.log(`${spec.pathLabel} path: ${path}`);
|
|
114
172
|
const files = await this.wp.get(spec.endpoint);
|
|
173
|
+
// A file the server would not read back (over the size limit, see LP-SEC-02) arrives
|
|
174
|
+
// with no content: report it and leave any local copy untouched rather than overwriting
|
|
175
|
+
// it with nothing.
|
|
176
|
+
const unreadable = files.filter((file) => typeof file.content !== 'string');
|
|
177
|
+
for (const file of unreadable) {
|
|
178
|
+
this.warn(`Skipped ${file.filename}: ${file.error ?? 'the server did not return its content'}`);
|
|
179
|
+
}
|
|
180
|
+
const readable = files.filter((file) => typeof file.content === 'string');
|
|
115
181
|
// A `<filename>.php` no longer present remotely was deleted on WordPress (push stays
|
|
116
|
-
// additive-only, but pull already cleans up locally, same as `snippet pull`).
|
|
182
|
+
// additive-only, but pull already cleans up locally, same as `snippet pull`). The
|
|
183
|
+
// unreadable ones still exist remotely, so keep them out of the orphan set.
|
|
117
184
|
const orphans = await findOrphanedFiles(path, new Set(files.map((file) => file.filename)), {
|
|
118
185
|
extensions: ['.php'],
|
|
119
186
|
key: basenameKey,
|
|
120
187
|
recursive: true,
|
|
121
188
|
});
|
|
122
|
-
const pulled =
|
|
123
|
-
await this.pullDirectory(path,
|
|
189
|
+
const pulled = readable.map((file) => file.filename);
|
|
190
|
+
await this.pullDirectory(path, readable, orphans, {
|
|
124
191
|
alwaysCreateDir: true,
|
|
125
|
-
dryRunMessage: `Would pull ${pluralize(
|
|
192
|
+
dryRunMessage: `Would pull ${pluralize(readable.length, spec.noun)} to ${path}`,
|
|
126
193
|
orphanReason: spec.orphanReason,
|
|
127
|
-
pulledMessage: `Pulled ${pluralize(
|
|
194
|
+
pulledMessage: `Pulled ${pluralize(readable.length, spec.noun)} to ${path}`,
|
|
128
195
|
title: (file) => file.filename,
|
|
129
196
|
async write(file, writeDir) {
|
|
130
197
|
const filePath = join(writeDir, `${file.filename}.php`);
|
|
@@ -156,13 +223,66 @@ export function resourceListCommand(spec) {
|
|
|
156
223
|
this.log(`Found ${pluralize(files.length, spec.noun)}:`);
|
|
157
224
|
this.log('');
|
|
158
225
|
for (const file of files) {
|
|
159
|
-
|
|
226
|
+
const badges = [file.public ? '[PUBLIC]' : '', file.error ? `(${file.error})` : ''].filter(Boolean).join(' ');
|
|
227
|
+
this.log(badges ? ` ${file.filename} ${badges}` : ` ${file.filename}`);
|
|
228
|
+
}
|
|
229
|
+
if (files.some((file) => file.public)) {
|
|
230
|
+
this.log('');
|
|
231
|
+
this.warn('[PUBLIC] routes run for anyone, with no authentication. Review them.');
|
|
160
232
|
}
|
|
161
233
|
return files;
|
|
162
234
|
}
|
|
163
235
|
}
|
|
164
236
|
return ResourceList;
|
|
165
237
|
}
|
|
238
|
+
export function resourceRmCommand(spec) {
|
|
239
|
+
class ResourceRm extends LoopressCommand {
|
|
240
|
+
static args = {
|
|
241
|
+
filename: Args.string({
|
|
242
|
+
description: `The ${spec.noun} to remove, its slug without the .php extension (e.g. "hello" or "invoice-pdf/[order_id]")`,
|
|
243
|
+
required: true,
|
|
244
|
+
}),
|
|
245
|
+
};
|
|
246
|
+
static description = spec.rmDescription;
|
|
247
|
+
static enableJsonFlag = true;
|
|
248
|
+
static examples = [`$ lps ${spec.cliName} rm hello`, `$ lps ${spec.cliName} rm hello --yes`];
|
|
249
|
+
static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
|
|
250
|
+
async run() {
|
|
251
|
+
const { args } = await this.parse(ResourceRm);
|
|
252
|
+
const { filename } = args;
|
|
253
|
+
const { url } = this.siteConfig;
|
|
254
|
+
if (!spec.filenamePattern.test(filename)) {
|
|
255
|
+
this.error(`Invalid filename "${filename}": ${spec.invalidFilenameHint}`);
|
|
256
|
+
}
|
|
257
|
+
if (this.dryRun) {
|
|
258
|
+
this.log(`[dry-run] Would remove ${filename} from ${url}`);
|
|
259
|
+
return { filename, removed: false, status: 'dry-run' };
|
|
260
|
+
}
|
|
261
|
+
if (!this.yes) {
|
|
262
|
+
if (!isInteractive()) {
|
|
263
|
+
this.error(`Removing ${filename} needs confirmation. Re-run with --yes.`);
|
|
264
|
+
}
|
|
265
|
+
const ok = await confirm({ default: false, message: `Remove ${filename} from ${url}?` });
|
|
266
|
+
if (!ok) {
|
|
267
|
+
this.log('Aborted.');
|
|
268
|
+
return { filename, removed: false, status: 'aborted' };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
await this.wp.delete(`${spec.endpoint}?filename=${encodeURIComponent(filename)}`);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (isApplicative404(error, 'File not found')) {
|
|
276
|
+
this.error(`${filename} is not on ${url}.`);
|
|
277
|
+
}
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
this.log(`Removed ${filename} from ${url}`);
|
|
281
|
+
return { filename, removed: true, status: 'success' };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return ResourceRm;
|
|
285
|
+
}
|
|
166
286
|
export const API_FILES_RESOURCE = {
|
|
167
287
|
// Best-effort report of the current route list to the Loopress cloud, purely for console
|
|
168
288
|
// visibility (US-18): the routes are already live on WordPress by this point, so this can
|
|
@@ -195,6 +315,7 @@ export const API_FILES_RESOURCE = {
|
|
|
195
315
|
pathNoun: 'api directory',
|
|
196
316
|
pullDescription: 'Pull custom API route files from WordPress',
|
|
197
317
|
pushDescription: 'Push custom API route files to WordPress',
|
|
318
|
+
rmDescription: 'Remove a custom API route file from WordPress',
|
|
198
319
|
};
|
|
199
320
|
export const HOOK_FILES_RESOURCE = {
|
|
200
321
|
cliName: 'hook',
|
|
@@ -213,4 +334,5 @@ export const HOOK_FILES_RESOURCE = {
|
|
|
213
334
|
pathNoun: 'hooks directory',
|
|
214
335
|
pullDescription: 'Pull hook files (actions, filters, cron) from WordPress',
|
|
215
336
|
pushDescription: 'Push hook files (actions, filters, cron) to WordPress',
|
|
337
|
+
rmDescription: 'Remove a hook file (action, filter, cron) from WordPress',
|
|
216
338
|
};
|
package/dist/lib/sentry.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ErrorEvent } from '@sentry/node';
|
|
1
2
|
export declare const SENTRY_DSN = "https://a08dd56bfffc2a45d5b8f665e4cb8b7d@o4511586904309760.ingest.de.sentry.io/4511673275973712";
|
|
2
3
|
export declare function isTelemetryDisabled(): boolean;
|
|
3
4
|
export declare function resolveEnvironment(): string;
|
|
@@ -6,3 +7,5 @@ export declare function runtimeContext(): {
|
|
|
6
7
|
os: string;
|
|
7
8
|
};
|
|
8
9
|
export declare function redactArgv(argv: string[]): string[];
|
|
10
|
+
export declare function scrubErrorMessage(value: string): string;
|
|
11
|
+
export declare function scrubEvent(event: ErrorEvent): ErrorEvent;
|