@loopress/cli 0.12.0 → 0.13.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 +45 -20
- package/dist/commands/login.d.ts +0 -1
- package/dist/commands/login.js +21 -90
- package/dist/commands/project/config.d.ts +2 -0
- package/dist/commands/project/config.js +45 -11
- package/dist/commands/snippet/publish.d.ts +10 -0
- package/dist/commands/snippet/publish.js +74 -0
- package/dist/commands/snippet/push.js +5 -70
- package/dist/lib/load-snippets.d.ts +2 -0
- package/dist/lib/load-snippets.js +84 -0
- package/dist/lib/local-callback-server.d.ts +25 -0
- package/dist/lib/local-callback-server.js +93 -0
- package/dist/lib/open-browser.d.ts +2 -0
- package/dist/lib/open-browser.js +12 -0
- package/dist/lib/wp-authorize-flow.d.ts +10 -0
- package/dist/lib/wp-authorize-flow.js +53 -0
- package/dist/lib/wp-site-diagnostic.d.ts +13 -0
- package/dist/lib/wp-site-diagnostic.js +46 -0
- package/oclif.manifest.json +32 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ $ npm install -g @loopress/cli
|
|
|
21
21
|
$ lps COMMAND
|
|
22
22
|
running command...
|
|
23
23
|
$ lps (--version)
|
|
24
|
-
@loopress/cli/0.
|
|
24
|
+
@loopress/cli/0.13.0 linux-x64 node-v24.18.0
|
|
25
25
|
$ lps --help [COMMAND]
|
|
26
26
|
USAGE
|
|
27
27
|
$ lps COMMAND
|
|
@@ -61,6 +61,7 @@ report.
|
|
|
61
61
|
* [`lps project switch`](#lps-project-switch)
|
|
62
62
|
* [`lps project sync`](#lps-project-sync)
|
|
63
63
|
* [`lps snippet list`](#lps-snippet-list)
|
|
64
|
+
* [`lps snippet publish [PATH]`](#lps-snippet-publish-path)
|
|
64
65
|
* [`lps snippet pull [PATH]`](#lps-snippet-pull-path)
|
|
65
66
|
* [`lps snippet push [PATH]`](#lps-snippet-push-path)
|
|
66
67
|
* [`lps status`](#lps-status)
|
|
@@ -85,7 +86,7 @@ EXAMPLES
|
|
|
85
86
|
$ lps composer pull --dry-run
|
|
86
87
|
```
|
|
87
88
|
|
|
88
|
-
_See code: [src/commands/composer/pull.ts](https://github.com/loopress/loopress/blob/v0.
|
|
89
|
+
_See code: [src/commands/composer/pull.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/composer/pull.ts)_
|
|
89
90
|
|
|
90
91
|
## `lps composer push`
|
|
91
92
|
|
|
@@ -107,7 +108,7 @@ EXAMPLES
|
|
|
107
108
|
$ lps composer push --dry-run
|
|
108
109
|
```
|
|
109
110
|
|
|
110
|
-
_See code: [src/commands/composer/push.ts](https://github.com/loopress/loopress/blob/v0.
|
|
111
|
+
_See code: [src/commands/composer/push.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/composer/push.ts)_
|
|
111
112
|
|
|
112
113
|
## `lps help [COMMAND]`
|
|
113
114
|
|
|
@@ -144,7 +145,7 @@ EXAMPLES
|
|
|
144
145
|
$ lps init
|
|
145
146
|
```
|
|
146
147
|
|
|
147
|
-
_See code: [src/commands/init.ts](https://github.com/loopress/loopress/blob/v0.
|
|
148
|
+
_See code: [src/commands/init.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/init.ts)_
|
|
148
149
|
|
|
149
150
|
## `lps login`
|
|
150
151
|
|
|
@@ -161,7 +162,7 @@ EXAMPLES
|
|
|
161
162
|
$ lps login
|
|
162
163
|
```
|
|
163
164
|
|
|
164
|
-
_See code: [src/commands/login.ts](https://github.com/loopress/loopress/blob/v0.
|
|
165
|
+
_See code: [src/commands/login.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/login.ts)_
|
|
165
166
|
|
|
166
167
|
## `lps logout`
|
|
167
168
|
|
|
@@ -178,7 +179,7 @@ EXAMPLES
|
|
|
178
179
|
$ lps logout
|
|
179
180
|
```
|
|
180
181
|
|
|
181
|
-
_See code: [src/commands/logout.ts](https://github.com/loopress/loopress/blob/v0.
|
|
182
|
+
_See code: [src/commands/logout.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/logout.ts)_
|
|
182
183
|
|
|
183
184
|
## `lps plugin add SLUG [VERSION]`
|
|
184
185
|
|
|
@@ -206,7 +207,7 @@ EXAMPLES
|
|
|
206
207
|
$ lps plugin add contact-form-7 --dry-run
|
|
207
208
|
```
|
|
208
209
|
|
|
209
|
-
_See code: [src/commands/plugin/add.ts](https://github.com/loopress/loopress/blob/v0.
|
|
210
|
+
_See code: [src/commands/plugin/add.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/plugin/add.ts)_
|
|
210
211
|
|
|
211
212
|
## `lps plugin pull`
|
|
212
213
|
|
|
@@ -228,7 +229,7 @@ EXAMPLES
|
|
|
228
229
|
$ lps plugin pull --dry-run
|
|
229
230
|
```
|
|
230
231
|
|
|
231
|
-
_See code: [src/commands/plugin/pull.ts](https://github.com/loopress/loopress/blob/v0.
|
|
232
|
+
_See code: [src/commands/plugin/pull.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/plugin/pull.ts)_
|
|
232
233
|
|
|
233
234
|
## `lps plugin push`
|
|
234
235
|
|
|
@@ -250,24 +251,25 @@ EXAMPLES
|
|
|
250
251
|
$ lps plugin push --dry-run
|
|
251
252
|
```
|
|
252
253
|
|
|
253
|
-
_See code: [src/commands/plugin/push.ts](https://github.com/loopress/loopress/blob/v0.
|
|
254
|
+
_See code: [src/commands/plugin/push.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/plugin/push.ts)_
|
|
254
255
|
|
|
255
256
|
## `lps project config`
|
|
256
257
|
|
|
257
|
-
Add or update a WordPress project environment
|
|
258
|
+
Add or update a WordPress project environment. By default, authorizes via WordPress in your browser; manual username/Application Password entry is available as a fallback.
|
|
258
259
|
|
|
259
260
|
```
|
|
260
261
|
USAGE
|
|
261
262
|
$ lps project config
|
|
262
263
|
|
|
263
264
|
DESCRIPTION
|
|
264
|
-
Add or update a WordPress project environment
|
|
265
|
+
Add or update a WordPress project environment. By default, authorizes via WordPress in your browser; manual
|
|
266
|
+
username/Application Password entry is available as a fallback.
|
|
265
267
|
|
|
266
268
|
EXAMPLES
|
|
267
269
|
$ lps project config
|
|
268
270
|
```
|
|
269
271
|
|
|
270
|
-
_See code: [src/commands/project/config.ts](https://github.com/loopress/loopress/blob/v0.
|
|
272
|
+
_See code: [src/commands/project/config.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/project/config.ts)_
|
|
271
273
|
|
|
272
274
|
## `lps project list`
|
|
273
275
|
|
|
@@ -284,7 +286,7 @@ EXAMPLES
|
|
|
284
286
|
$ lps project list
|
|
285
287
|
```
|
|
286
288
|
|
|
287
|
-
_See code: [src/commands/project/list.ts](https://github.com/loopress/loopress/blob/v0.
|
|
289
|
+
_See code: [src/commands/project/list.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/project/list.ts)_
|
|
288
290
|
|
|
289
291
|
## `lps project remove`
|
|
290
292
|
|
|
@@ -301,7 +303,7 @@ EXAMPLES
|
|
|
301
303
|
$ lps project remove
|
|
302
304
|
```
|
|
303
305
|
|
|
304
|
-
_See code: [src/commands/project/remove.ts](https://github.com/loopress/loopress/blob/v0.
|
|
306
|
+
_See code: [src/commands/project/remove.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/project/remove.ts)_
|
|
305
307
|
|
|
306
308
|
## `lps project switch`
|
|
307
309
|
|
|
@@ -318,7 +320,7 @@ EXAMPLES
|
|
|
318
320
|
$ lps project switch
|
|
319
321
|
```
|
|
320
322
|
|
|
321
|
-
_See code: [src/commands/project/switch.ts](https://github.com/loopress/loopress/blob/v0.
|
|
323
|
+
_See code: [src/commands/project/switch.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/project/switch.ts)_
|
|
322
324
|
|
|
323
325
|
## `lps project sync`
|
|
324
326
|
|
|
@@ -335,7 +337,7 @@ EXAMPLES
|
|
|
335
337
|
$ lps project sync
|
|
336
338
|
```
|
|
337
339
|
|
|
338
|
-
_See code: [src/commands/project/sync.ts](https://github.com/loopress/loopress/blob/v0.
|
|
340
|
+
_See code: [src/commands/project/sync.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/project/sync.ts)_
|
|
339
341
|
|
|
340
342
|
## `lps snippet list`
|
|
341
343
|
|
|
@@ -355,7 +357,30 @@ EXAMPLES
|
|
|
355
357
|
$ lps snippet list
|
|
356
358
|
```
|
|
357
359
|
|
|
358
|
-
_See code: [src/commands/snippet/list.ts](https://github.com/loopress/loopress/blob/v0.
|
|
360
|
+
_See code: [src/commands/snippet/list.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/snippet/list.ts)_
|
|
361
|
+
|
|
362
|
+
## `lps snippet publish [PATH]`
|
|
363
|
+
|
|
364
|
+
Publish snippets to your Loopress account so they can be deployed to other projects. Does not touch any WordPress site.
|
|
365
|
+
|
|
366
|
+
```
|
|
367
|
+
USAGE
|
|
368
|
+
$ lps snippet publish [PATH]
|
|
369
|
+
|
|
370
|
+
ARGUMENTS
|
|
371
|
+
[PATH] Path to snippets directory (overrides project config)
|
|
372
|
+
|
|
373
|
+
DESCRIPTION
|
|
374
|
+
Publish snippets to your Loopress account so they can be deployed to other projects. Does not touch any WordPress
|
|
375
|
+
site.
|
|
376
|
+
|
|
377
|
+
EXAMPLES
|
|
378
|
+
$ lps snippet publish
|
|
379
|
+
|
|
380
|
+
$ lps snippet publish --path ./snippets
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
_See code: [src/commands/snippet/publish.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/snippet/publish.ts)_
|
|
359
384
|
|
|
360
385
|
## `lps snippet pull [PATH]`
|
|
361
386
|
|
|
@@ -380,7 +405,7 @@ EXAMPLES
|
|
|
380
405
|
$ lps snippet pull --path ./snippets
|
|
381
406
|
```
|
|
382
407
|
|
|
383
|
-
_See code: [src/commands/snippet/pull.ts](https://github.com/loopress/loopress/blob/v0.
|
|
408
|
+
_See code: [src/commands/snippet/pull.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/snippet/pull.ts)_
|
|
384
409
|
|
|
385
410
|
## `lps snippet push [PATH]`
|
|
386
411
|
|
|
@@ -406,7 +431,7 @@ EXAMPLES
|
|
|
406
431
|
$ lps snippet push --path ./snippets
|
|
407
432
|
```
|
|
408
433
|
|
|
409
|
-
_See code: [src/commands/snippet/push.ts](https://github.com/loopress/loopress/blob/v0.
|
|
434
|
+
_See code: [src/commands/snippet/push.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/snippet/push.ts)_
|
|
410
435
|
|
|
411
436
|
## `lps status`
|
|
412
437
|
|
|
@@ -423,5 +448,5 @@ EXAMPLES
|
|
|
423
448
|
$ lps status
|
|
424
449
|
```
|
|
425
450
|
|
|
426
|
-
_See code: [src/commands/status.ts](https://github.com/loopress/loopress/blob/v0.
|
|
451
|
+
_See code: [src/commands/status.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/status.ts)_
|
|
427
452
|
<!-- commandsstop -->
|
package/dist/commands/login.d.ts
CHANGED
package/dist/commands/login.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { Command } from '@oclif/core';
|
|
2
|
-
import { exec } from 'node:child_process';
|
|
3
|
-
import { createServer } from 'node:http';
|
|
4
2
|
import { authManager } from '../config/auth.manager.js';
|
|
3
|
+
import { renderResultPage, waitForLocalCallback } from '../lib/local-callback-server.js';
|
|
5
4
|
const CONSOLE_URL = 'https://console.loopress.dev';
|
|
6
|
-
const TIMEOUT_MS = 5 * 60 * 1000;
|
|
7
5
|
export default class Login extends Command {
|
|
8
6
|
static description = 'Log in to the Loopress console';
|
|
9
7
|
static examples = ['$ lps login'];
|
|
@@ -12,95 +10,28 @@ export default class Login extends Command {
|
|
|
12
10
|
authManager.setAuth({ email, savedAt: new Date().toISOString(), token });
|
|
13
11
|
this.log(`\nLogged in${email ? ` as ${email}` : ''}. You're all set!`);
|
|
14
12
|
}
|
|
15
|
-
openBrowser(url) {
|
|
16
|
-
const cmds = {
|
|
17
|
-
darwin: `open "${url}"`,
|
|
18
|
-
linux: `xdg-open "${url}"`,
|
|
19
|
-
win32: `start "" "${url}"`,
|
|
20
|
-
};
|
|
21
|
-
const cmd = cmds[process.platform];
|
|
22
|
-
if (cmd)
|
|
23
|
-
exec(cmd);
|
|
24
|
-
}
|
|
25
13
|
waitForCallback() {
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
res.end('Missing token');
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
38
|
-
res.end(SUCCESS_PAGE);
|
|
39
|
-
clearTimeout(timer);
|
|
40
|
-
server.close();
|
|
41
|
-
resolve({ email, token });
|
|
42
|
-
}
|
|
43
|
-
catch (error) {
|
|
44
|
-
res.writeHead(500);
|
|
45
|
-
res.end('Internal error');
|
|
46
|
-
server.close();
|
|
47
|
-
reject(error);
|
|
14
|
+
return waitForLocalCallback({
|
|
15
|
+
buildUrl: (callbackBaseUrl) => `${CONSOLE_URL}/cli-auth?callbackUrl=${encodeURIComponent(`${callbackBaseUrl}/callback`)}`,
|
|
16
|
+
handleRequest(url, { resolveWithPage, respondBadRequest }) {
|
|
17
|
+
const token = url.searchParams.get('token');
|
|
18
|
+
const email = url.searchParams.get('email') ?? undefined;
|
|
19
|
+
if (!token) {
|
|
20
|
+
respondBadRequest('Missing token');
|
|
21
|
+
return;
|
|
48
22
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const timer = setTimeout(() => {
|
|
55
|
-
server.close();
|
|
56
|
-
reject(new Error('Login timed out after 5 minutes. Please try again.'));
|
|
57
|
-
}, TIMEOUT_MS);
|
|
58
|
-
server.listen(0, '127.0.0.1', () => {
|
|
59
|
-
const { port } = server.address();
|
|
60
|
-
const callbackUrl = `http://localhost:${port}/callback`;
|
|
61
|
-
const loginUrl = `${CONSOLE_URL}/cli-auth?callbackUrl=${encodeURIComponent(callbackUrl)}`;
|
|
62
|
-
this.log('Opening Loopress console in your browser...');
|
|
63
|
-
this.log(`\nIf it doesn't open automatically, visit:\n${loginUrl}\n`);
|
|
64
|
-
this.openBrowser(loginUrl);
|
|
65
|
-
});
|
|
23
|
+
resolveWithPage(SUCCESS_PAGE, { email, token });
|
|
24
|
+
},
|
|
25
|
+
log: (message) => this.log(message),
|
|
26
|
+
openingMessage: 'Opening Loopress console in your browser...',
|
|
27
|
+
timeoutMessage: 'Login timed out after 5 minutes. Please try again.',
|
|
66
28
|
});
|
|
67
29
|
}
|
|
68
30
|
}
|
|
69
|
-
const SUCCESS_PAGE =
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
77
|
-
body {
|
|
78
|
-
font-family: system-ui, -apple-system, sans-serif;
|
|
79
|
-
background: #f0fdf4;
|
|
80
|
-
display: flex;
|
|
81
|
-
align-items: center;
|
|
82
|
-
justify-content: center;
|
|
83
|
-
min-height: 100dvh;
|
|
84
|
-
}
|
|
85
|
-
.card {
|
|
86
|
-
background: #fff;
|
|
87
|
-
border-radius: 16px;
|
|
88
|
-
padding: 2.5rem 3rem;
|
|
89
|
-
text-align: center;
|
|
90
|
-
box-shadow: 0 4px 32px rgba(0, 0, 0, .08);
|
|
91
|
-
max-width: 420px;
|
|
92
|
-
width: 90%;
|
|
93
|
-
}
|
|
94
|
-
.icon { font-size: 3rem; margin-bottom: 1rem; }
|
|
95
|
-
h1 { color: #15803d; font-size: 1.5rem; margin-bottom: .5rem; }
|
|
96
|
-
p { color: #6b7280; font-size: .95rem; line-height: 1.5; }
|
|
97
|
-
</style>
|
|
98
|
-
</head>
|
|
99
|
-
<body>
|
|
100
|
-
<div class="card">
|
|
101
|
-
<div class="icon">✅</div>
|
|
102
|
-
<h1>Login successful!</h1>
|
|
103
|
-
<p>You can close this tab and return to your terminal.</p>
|
|
104
|
-
</div>
|
|
105
|
-
</body>
|
|
106
|
-
</html>`;
|
|
31
|
+
const SUCCESS_PAGE = renderResultPage({
|
|
32
|
+
background: '#f0fdf4',
|
|
33
|
+
heading: 'Login successful!',
|
|
34
|
+
headingColor: '#15803d',
|
|
35
|
+
icon: '✅',
|
|
36
|
+
tabTitle: 'Logged in',
|
|
37
|
+
});
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { confirm, input, password as passwordPrompt, select } from '@inquirer/prompts';
|
|
2
2
|
import { Command } from '@oclif/core';
|
|
3
3
|
import { configManager } from '../../config/project-config.manager.js';
|
|
4
|
+
import { authorizeWithBrowser } from '../../lib/wp-authorize-flow.js';
|
|
5
|
+
import { diagnoseWpSite } from '../../lib/wp-site-diagnostic.js';
|
|
4
6
|
const NEW_PROJECT = '__new__';
|
|
7
|
+
const AUTH_BROWSER = 'browser';
|
|
8
|
+
const AUTH_MANUAL = 'manual';
|
|
5
9
|
export default class Config extends Command {
|
|
6
|
-
static description = 'Add or update a WordPress project environment';
|
|
10
|
+
static description = 'Add or update a WordPress project environment. By default, authorizes via WordPress in your browser; manual username/Application Password entry is available as a fallback.';
|
|
7
11
|
static examples = ['$ lps project config'];
|
|
8
12
|
async run() {
|
|
9
13
|
await this.parse(Config);
|
|
@@ -34,7 +38,7 @@ export default class Config extends Command {
|
|
|
34
38
|
return;
|
|
35
39
|
}
|
|
36
40
|
}
|
|
37
|
-
const
|
|
41
|
+
const rawUrl = await input({
|
|
38
42
|
message: 'WordPress URL',
|
|
39
43
|
validate(value) {
|
|
40
44
|
try {
|
|
@@ -49,15 +53,8 @@ export default class Config extends Command {
|
|
|
49
53
|
}
|
|
50
54
|
},
|
|
51
55
|
});
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
validate: (value) => (value.trim().length > 0 ? true : 'Username cannot be empty'),
|
|
55
|
-
});
|
|
56
|
-
const appPassword = await passwordPrompt({
|
|
57
|
-
mask: '*',
|
|
58
|
-
message: 'Application password',
|
|
59
|
-
validate: (value) => (value.trim().length > 0 ? true : 'Application password cannot be empty'),
|
|
60
|
-
});
|
|
56
|
+
const url = rawUrl.replace(/\/+$/, '');
|
|
57
|
+
const { appPassword, user } = await this.resolveCredentials(url);
|
|
61
58
|
const token = `${user}:${appPassword}`;
|
|
62
59
|
const env = {
|
|
63
60
|
addedAt: new Date().toISOString(),
|
|
@@ -79,6 +76,43 @@ export default class Config extends Command {
|
|
|
79
76
|
this.log(`✓ "${projectName}/${envName}" configured`);
|
|
80
77
|
this.log('→ Run `lps project switch` to change the active project or environment');
|
|
81
78
|
}
|
|
79
|
+
async promptManualCredentials() {
|
|
80
|
+
const user = await input({
|
|
81
|
+
message: 'Username',
|
|
82
|
+
validate: (value) => (value.trim().length > 0 ? true : 'Username cannot be empty'),
|
|
83
|
+
});
|
|
84
|
+
const appPassword = await passwordPrompt({
|
|
85
|
+
mask: '*',
|
|
86
|
+
message: 'Application password',
|
|
87
|
+
validate: (value) => (value.trim().length > 0 ? true : 'Application password cannot be empty'),
|
|
88
|
+
});
|
|
89
|
+
return { appPassword, user };
|
|
90
|
+
}
|
|
91
|
+
async resolveCredentials(url) {
|
|
92
|
+
const authMode = await select({
|
|
93
|
+
choices: [
|
|
94
|
+
{ name: 'Authorize in my browser (recommended)', value: AUTH_BROWSER },
|
|
95
|
+
{ name: 'Enter credentials manually', value: AUTH_MANUAL },
|
|
96
|
+
],
|
|
97
|
+
message: 'How do you want to authenticate?',
|
|
98
|
+
});
|
|
99
|
+
if (authMode === AUTH_MANUAL) {
|
|
100
|
+
return this.promptManualCredentials();
|
|
101
|
+
}
|
|
102
|
+
const diagnostic = await diagnoseWpSite(url);
|
|
103
|
+
if (!diagnostic.ok) {
|
|
104
|
+
this.warn(`${diagnostic.reason}\nFalling back to manual credential entry.`);
|
|
105
|
+
return this.promptManualCredentials();
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const { password, userLogin } = await authorizeWithBrowser(url, (message) => this.log(message));
|
|
109
|
+
return { appPassword: password, user: userLogin };
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
this.warn(`${error.message}\nFalling back to manual credential entry.`);
|
|
113
|
+
return this.promptManualCredentials();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
82
116
|
async resolveProject() {
|
|
83
117
|
const projects = configManager.listProjects();
|
|
84
118
|
if (projects.length > 0) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class Publish extends Command {
|
|
3
|
+
static args: {
|
|
4
|
+
path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
run(): Promise<void>;
|
|
9
|
+
private toPayload;
|
|
10
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Args, Command } from '@oclif/core';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import slugify from 'slugify';
|
|
4
|
+
import { authManager } from '../../config/auth.manager.js';
|
|
5
|
+
import { configManager } from '../../config/project-config.manager.js';
|
|
6
|
+
import { ApiClient } from '../../lib/api-client.js';
|
|
7
|
+
import { loadSnippets } from '../../lib/load-snippets.js';
|
|
8
|
+
import { readLocalConfig } from '../../utils/loopress-config.js';
|
|
9
|
+
// Publishes to the Loopress api (not a WordPress site), so this does not extend
|
|
10
|
+
// `LoopressCommand`/`PushCommand`: those force an environment to be resolved, but a project
|
|
11
|
+
// publishing its snippets for sharing doesn't need one, a pure "library" project
|
|
12
|
+
// may have zero environments configured at all.
|
|
13
|
+
export default class Publish extends Command {
|
|
14
|
+
static args = {
|
|
15
|
+
path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
|
|
16
|
+
};
|
|
17
|
+
static description = 'Publish snippets to your Loopress account so they can be deployed to other projects. Does not touch any WordPress site.';
|
|
18
|
+
static examples = ['$ lps snippet publish', '$ lps snippet publish --path ./snippets'];
|
|
19
|
+
async run() {
|
|
20
|
+
const { args } = await this.parse(Publish);
|
|
21
|
+
const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token;
|
|
22
|
+
if (!token) {
|
|
23
|
+
this.error('Not logged in. Run `lps login` first.');
|
|
24
|
+
}
|
|
25
|
+
const localConfig = await readLocalConfig();
|
|
26
|
+
const projectId = localConfig.projectId ?? configManager.getCurrentProject()?.id;
|
|
27
|
+
if (!projectId) {
|
|
28
|
+
this.error('No project configured. Run `lps project config` first.');
|
|
29
|
+
}
|
|
30
|
+
const project = configManager.getProject(projectId);
|
|
31
|
+
if (!project) {
|
|
32
|
+
this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
|
|
33
|
+
}
|
|
34
|
+
if (!project.apiProjectId) {
|
|
35
|
+
this.error(`Project "${project.name}" is not linked to your Loopress account yet. Run \`lps project sync\` first.`);
|
|
36
|
+
}
|
|
37
|
+
const path = args.path ?? join(localConfig.rootDir ?? '.', localConfig.snippetsDir ?? 'snippets');
|
|
38
|
+
this.log(`Publishing snippets from ${path}`);
|
|
39
|
+
let snippets;
|
|
40
|
+
try {
|
|
41
|
+
snippets = await loadSnippets(path);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
this.error(error.message);
|
|
45
|
+
}
|
|
46
|
+
const api = new ApiClient(token);
|
|
47
|
+
try {
|
|
48
|
+
await api.post(`projects/${project.apiProjectId}/snippets/publish`, {
|
|
49
|
+
snippets: snippets.map((snippet) => this.toPayload(snippet)),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
this.error(error.message);
|
|
54
|
+
}
|
|
55
|
+
this.log(`Published ${snippets.length} snippet${snippets.length === 1 ? '' : 's'} to your Loopress account.`);
|
|
56
|
+
}
|
|
57
|
+
toPayload(snippet) {
|
|
58
|
+
return {
|
|
59
|
+
active: snippet.active,
|
|
60
|
+
code: snippet.code,
|
|
61
|
+
insertMethod: snippet.insertMethod,
|
|
62
|
+
location: snippet.location,
|
|
63
|
+
name: snippet.name,
|
|
64
|
+
priority: snippet.priority,
|
|
65
|
+
shortcodeAttributes: snippet.shortcodeAttributes,
|
|
66
|
+
// Derived from the snippet's name rather than its on-disk filename: the same
|
|
67
|
+
// slugification `push`/`pull` already use for the canonical `<id>-<slug>` filename
|
|
68
|
+
// convention, so it stays stable even for a not-yet-pushed file with an arbitrary name.
|
|
69
|
+
slug: slugify(snippet.name, { lower: true, strict: true }),
|
|
70
|
+
tags: snippet.tags,
|
|
71
|
+
type: snippet.type,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
|
-
import {
|
|
3
|
+
import { readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, extname, join } from 'node:path';
|
|
5
5
|
import slugify from 'slugify';
|
|
6
|
+
import { loadSnippets as loadSnippetsFromDisk } from '../../lib/load-snippets.js';
|
|
6
7
|
import { PushCommand } from '../../lib/push-command.js';
|
|
7
8
|
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
8
|
-
import {
|
|
9
|
-
const TYPE_BY_EXTENSION = {
|
|
10
|
-
'.css': 'css',
|
|
11
|
-
'.html': 'html',
|
|
12
|
-
'.js': 'js',
|
|
13
|
-
'.php': 'php',
|
|
14
|
-
'.txt': 'text',
|
|
15
|
-
};
|
|
9
|
+
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../../utils/snippet-format.js';
|
|
16
10
|
export default class Push extends PushCommand {
|
|
17
11
|
static args = {
|
|
18
12
|
path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
|
|
@@ -78,71 +72,12 @@ export default class Push extends PushCommand {
|
|
|
78
72
|
await rm(oldMetaPath, { force: true });
|
|
79
73
|
}
|
|
80
74
|
async loadSnippets(path) {
|
|
81
|
-
const snippets = [];
|
|
82
|
-
let files;
|
|
83
75
|
try {
|
|
84
|
-
|
|
76
|
+
return await loadSnippetsFromDisk(path, (message) => this.warn(message));
|
|
85
77
|
}
|
|
86
78
|
catch (error) {
|
|
87
|
-
this.error(
|
|
79
|
+
this.error(error.message);
|
|
88
80
|
}
|
|
89
|
-
for (const file of files) {
|
|
90
|
-
const ext = extname(file);
|
|
91
|
-
if (!(ext in TYPE_BY_EXTENSION))
|
|
92
|
-
continue;
|
|
93
|
-
const filePath = join(path, file);
|
|
94
|
-
const metaPath = join(path, `${basename(file, ext)}.json`);
|
|
95
|
-
// One snippet's files are read in isolation: a corrupted or hand-broken sidecar
|
|
96
|
-
// (bad JSON, unreadable file, ...) must only skip that snippet, not abort the entire
|
|
97
|
-
// push for every other snippet in the directory.
|
|
98
|
-
try {
|
|
99
|
-
const content = await readFile(filePath, 'utf8');
|
|
100
|
-
let id;
|
|
101
|
-
let name;
|
|
102
|
-
let type;
|
|
103
|
-
let active = false;
|
|
104
|
-
let tags = [];
|
|
105
|
-
let location = null;
|
|
106
|
-
let insertMethod = null;
|
|
107
|
-
let priority = 10;
|
|
108
|
-
let shortcodeAttributes = [];
|
|
109
|
-
try {
|
|
110
|
-
const metaContent = await readFile(metaPath, 'utf8');
|
|
111
|
-
const meta = JSON.parse(metaContent);
|
|
112
|
-
id = meta.id ? Number(meta.id) : undefined;
|
|
113
|
-
name = meta.name ? String(meta.name) : undefined;
|
|
114
|
-
type = parseType(meta.type) ?? undefined;
|
|
115
|
-
active = Boolean(meta.active);
|
|
116
|
-
tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
117
|
-
location = parseLocation(meta.location);
|
|
118
|
-
insertMethod = parseInsertMethod(meta.insertMethod);
|
|
119
|
-
priority = meta.priority === undefined ? 10 : Number(meta.priority);
|
|
120
|
-
shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
|
|
121
|
-
}
|
|
122
|
-
catch (error) {
|
|
123
|
-
if (error.code !== 'ENOENT')
|
|
124
|
-
throw error;
|
|
125
|
-
}
|
|
126
|
-
const resolvedType = type ?? (ext in TYPE_BY_EXTENSION ? TYPE_BY_EXTENSION[ext] : 'php');
|
|
127
|
-
snippets.push({
|
|
128
|
-
active,
|
|
129
|
-
code: content,
|
|
130
|
-
id,
|
|
131
|
-
insertMethod: insertMethod ?? 'auto',
|
|
132
|
-
location: location ?? defaultLocationForType(resolvedType),
|
|
133
|
-
name: name ?? basename(file, ext),
|
|
134
|
-
path: filePath,
|
|
135
|
-
priority,
|
|
136
|
-
shortcodeAttributes,
|
|
137
|
-
tags,
|
|
138
|
-
type: resolvedType,
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
catch (error) {
|
|
142
|
-
this.warn(`Skipping "${metaPath}": ${error.message}`);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return snippets;
|
|
146
81
|
}
|
|
147
82
|
// Throwing on failure (rather than returning a boolean) is what lets Listr mark the task as
|
|
148
83
|
// failed (red cross) instead of completed; `exitOnError: false` on the task list still lets
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, extname, join } from 'node:path';
|
|
3
|
+
import { defaultLocationForType, parseInsertMethod, parseLocation, parseType, } from '../utils/snippet-format.js';
|
|
4
|
+
const TYPE_BY_EXTENSION = {
|
|
5
|
+
'.css': 'css',
|
|
6
|
+
'.html': 'html',
|
|
7
|
+
'.js': 'js',
|
|
8
|
+
'.php': 'php',
|
|
9
|
+
'.txt': 'text',
|
|
10
|
+
};
|
|
11
|
+
// Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
|
|
12
|
+
// (publishes to the Loopress api as a shared source) — both read the same local `snippets/`
|
|
13
|
+
// directory the same way. `onSkip` is optional so callers that don't care about per-file
|
|
14
|
+
// warnings (e.g. `snippet publish`) can ignore them; skipped files are simply left out either way.
|
|
15
|
+
export async function loadSnippets(path, onSkip) {
|
|
16
|
+
const snippets = [];
|
|
17
|
+
let files;
|
|
18
|
+
try {
|
|
19
|
+
files = await readdir(path);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
throw new Error(`Error loading snippets: ${error.message}`);
|
|
23
|
+
}
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
const ext = extname(file);
|
|
26
|
+
if (!(ext in TYPE_BY_EXTENSION))
|
|
27
|
+
continue;
|
|
28
|
+
const filePath = join(path, file);
|
|
29
|
+
const metaPath = join(path, `${basename(file, ext)}.json`);
|
|
30
|
+
// One snippet's files are read in isolation: a corrupted or hand-broken sidecar (bad JSON,
|
|
31
|
+
// unreadable file, ...) must only skip that snippet, not abort loading every other one.
|
|
32
|
+
let content;
|
|
33
|
+
try {
|
|
34
|
+
content = await readFile(filePath, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
onSkip?.(`Skipping "${filePath}": ${error.message}`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
let id;
|
|
41
|
+
let name;
|
|
42
|
+
let type;
|
|
43
|
+
let active = false;
|
|
44
|
+
let tags = [];
|
|
45
|
+
let location = null;
|
|
46
|
+
let insertMethod = null;
|
|
47
|
+
let priority = 10;
|
|
48
|
+
let shortcodeAttributes = [];
|
|
49
|
+
try {
|
|
50
|
+
const metaContent = await readFile(metaPath, 'utf8');
|
|
51
|
+
const meta = JSON.parse(metaContent);
|
|
52
|
+
id = meta.id === undefined ? undefined : Number(meta.id);
|
|
53
|
+
name = meta.name ? String(meta.name) : undefined;
|
|
54
|
+
type = parseType(meta.type) ?? undefined;
|
|
55
|
+
active = Boolean(meta.active);
|
|
56
|
+
tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
57
|
+
location = parseLocation(meta.location);
|
|
58
|
+
insertMethod = parseInsertMethod(meta.insertMethod);
|
|
59
|
+
priority = meta.priority === undefined ? 10 : Number(meta.priority);
|
|
60
|
+
shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code !== 'ENOENT') {
|
|
64
|
+
onSkip?.(`Skipping "${metaPath}": ${error.message}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const resolvedType = type ?? (ext in TYPE_BY_EXTENSION ? TYPE_BY_EXTENSION[ext] : 'php');
|
|
69
|
+
snippets.push({
|
|
70
|
+
active,
|
|
71
|
+
code: content,
|
|
72
|
+
id,
|
|
73
|
+
insertMethod: insertMethod ?? 'auto',
|
|
74
|
+
location: location ?? defaultLocationForType(resolvedType),
|
|
75
|
+
name: name ?? basename(file, ext),
|
|
76
|
+
path: filePath,
|
|
77
|
+
priority,
|
|
78
|
+
shortcodeAttributes,
|
|
79
|
+
tags,
|
|
80
|
+
type: resolvedType,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return snippets;
|
|
84
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type CallbackHelpers<T> = {
|
|
2
|
+
rejectWithPage: (page: string, error: Error) => void;
|
|
3
|
+
resolveWithPage: (page: string, value: T) => void;
|
|
4
|
+
respondBadRequest: (message: string) => void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Both `login` and `authorizeWithBrowser` send the user to a URL in their browser and need to
|
|
8
|
+
* catch the resulting redirect on a short-lived local server; this factors out the server setup,
|
|
9
|
+
* timeout, and browser-opening boilerplate they'd otherwise duplicate.
|
|
10
|
+
*/
|
|
11
|
+
export declare function waitForLocalCallback<T>(options: {
|
|
12
|
+
buildUrl: (callbackBaseUrl: string) => string;
|
|
13
|
+
handleRequest: (url: URL, helpers: CallbackHelpers<T>) => void;
|
|
14
|
+
log: (message: string) => void;
|
|
15
|
+
openingMessage: string;
|
|
16
|
+
timeoutMessage: string;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}): Promise<T>;
|
|
19
|
+
export declare function renderResultPage(options: {
|
|
20
|
+
background: string;
|
|
21
|
+
heading: string;
|
|
22
|
+
headingColor: string;
|
|
23
|
+
icon: string;
|
|
24
|
+
tabTitle: string;
|
|
25
|
+
}): string;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { openBrowser } from './open-browser.js';
|
|
3
|
+
/**
|
|
4
|
+
* Both `login` and `authorizeWithBrowser` send the user to a URL in their browser and need to
|
|
5
|
+
* catch the resulting redirect on a short-lived local server; this factors out the server setup,
|
|
6
|
+
* timeout, and browser-opening boilerplate they'd otherwise duplicate.
|
|
7
|
+
*/
|
|
8
|
+
export function waitForLocalCallback(options) {
|
|
9
|
+
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
function finish(res, page, settle) {
|
|
12
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
13
|
+
res.end(page);
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
server.close();
|
|
16
|
+
settle();
|
|
17
|
+
}
|
|
18
|
+
const server = createServer((req, res) => {
|
|
19
|
+
try {
|
|
20
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
21
|
+
options.handleRequest(url, {
|
|
22
|
+
rejectWithPage: (page, error) => finish(res, page, () => reject(error)),
|
|
23
|
+
resolveWithPage: (page, value) => finish(res, page, () => resolve(value)),
|
|
24
|
+
respondBadRequest(message) {
|
|
25
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
26
|
+
res.end(message);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
res.writeHead(500);
|
|
32
|
+
res.end('Internal error');
|
|
33
|
+
server.close();
|
|
34
|
+
reject(error);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
server.on('error', (err) => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
reject(err);
|
|
40
|
+
});
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
server.close();
|
|
43
|
+
reject(new Error(options.timeoutMessage));
|
|
44
|
+
}, timeoutMs);
|
|
45
|
+
server.listen(0, '127.0.0.1', () => {
|
|
46
|
+
const { port } = server.address();
|
|
47
|
+
const targetUrl = options.buildUrl(`http://localhost:${port}`);
|
|
48
|
+
options.log(options.openingMessage);
|
|
49
|
+
options.log(`\nIf it doesn't open automatically, visit:\n${targetUrl}\n`);
|
|
50
|
+
openBrowser(targetUrl);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export function renderResultPage(options) {
|
|
55
|
+
return `<!DOCTYPE html>
|
|
56
|
+
<html lang="en">
|
|
57
|
+
<head>
|
|
58
|
+
<meta charset="UTF-8">
|
|
59
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
60
|
+
<title>Loopress: ${options.tabTitle}</title>
|
|
61
|
+
<style>
|
|
62
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
63
|
+
body {
|
|
64
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
65
|
+
background: ${options.background};
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
justify-content: center;
|
|
69
|
+
min-height: 100dvh;
|
|
70
|
+
}
|
|
71
|
+
.card {
|
|
72
|
+
background: #fff;
|
|
73
|
+
border-radius: 16px;
|
|
74
|
+
padding: 2.5rem 3rem;
|
|
75
|
+
text-align: center;
|
|
76
|
+
box-shadow: 0 4px 32px rgba(0, 0, 0, .08);
|
|
77
|
+
max-width: 420px;
|
|
78
|
+
width: 90%;
|
|
79
|
+
}
|
|
80
|
+
.icon { font-size: 3rem; margin-bottom: 1rem; }
|
|
81
|
+
h1 { color: ${options.headingColor}; font-size: 1.5rem; margin-bottom: .5rem; }
|
|
82
|
+
p { color: #6b7280; font-size: .95rem; line-height: 1.5; }
|
|
83
|
+
</style>
|
|
84
|
+
</head>
|
|
85
|
+
<body>
|
|
86
|
+
<div class="card">
|
|
87
|
+
<div class="icon">${options.icon}</div>
|
|
88
|
+
<h1>${options.heading}</h1>
|
|
89
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
90
|
+
</div>
|
|
91
|
+
</body>
|
|
92
|
+
</html>`;
|
|
93
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
/** Opens `url` in the user's default browser, best-effort (no-op on unknown platforms). */
|
|
3
|
+
export function openBrowser(url) {
|
|
4
|
+
const commands = {
|
|
5
|
+
darwin: ['open', [url]],
|
|
6
|
+
linux: ['xdg-open', [url]],
|
|
7
|
+
win32: ['cmd', ['/c', 'start', '', url]],
|
|
8
|
+
};
|
|
9
|
+
const command = commands[process.platform];
|
|
10
|
+
if (command)
|
|
11
|
+
execFile(...command);
|
|
12
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type AuthorizeResult = {
|
|
2
|
+
password: string;
|
|
3
|
+
userLogin: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Runs WordPress's native "authorize application" flow: opens a local callback server,
|
|
7
|
+
* sends the user to `<siteUrl>/wp-admin/authorize-application.php`, and resolves with the
|
|
8
|
+
* generated Application Password once WordPress redirects back.
|
|
9
|
+
*/
|
|
10
|
+
export declare function authorizeWithBrowser(siteUrl: string, log: (message: string) => void): Promise<AuthorizeResult>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { renderResultPage, waitForLocalCallback } from './local-callback-server.js';
|
|
3
|
+
const APP_NAME = 'Loopress';
|
|
4
|
+
/**
|
|
5
|
+
* Runs WordPress's native "authorize application" flow: opens a local callback server,
|
|
6
|
+
* sends the user to `<siteUrl>/wp-admin/authorize-application.php`, and resolves with the
|
|
7
|
+
* generated Application Password once WordPress redirects back.
|
|
8
|
+
*/
|
|
9
|
+
export function authorizeWithBrowser(siteUrl, log) {
|
|
10
|
+
const state = randomBytes(32).toString('hex');
|
|
11
|
+
return waitForLocalCallback({
|
|
12
|
+
buildUrl(callbackBaseUrl) {
|
|
13
|
+
const successUrl = `${callbackBaseUrl}/callback?state=${state}`;
|
|
14
|
+
const rejectUrl = `${callbackBaseUrl}/reject?state=${state}`;
|
|
15
|
+
return (`${siteUrl}/wp-admin/authorize-application.php?app_name=${encodeURIComponent(APP_NAME)}` +
|
|
16
|
+
`&success_url=${encodeURIComponent(successUrl)}&reject_url=${encodeURIComponent(rejectUrl)}`);
|
|
17
|
+
},
|
|
18
|
+
handleRequest(url, { rejectWithPage, resolveWithPage, respondBadRequest }) {
|
|
19
|
+
if (url.searchParams.get('state') !== state) {
|
|
20
|
+
respondBadRequest('Invalid or missing state');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (url.pathname === '/reject') {
|
|
24
|
+
rejectWithPage(REJECTED_PAGE, new Error('Authorization rejected in WordPress. You can enter credentials manually instead.'));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const userLogin = url.searchParams.get('user_login');
|
|
28
|
+
const password = url.searchParams.get('password');
|
|
29
|
+
if (!userLogin || !password) {
|
|
30
|
+
respondBadRequest('Missing user_login or password');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
resolveWithPage(SUCCESS_PAGE, { password, userLogin });
|
|
34
|
+
},
|
|
35
|
+
log,
|
|
36
|
+
openingMessage: 'Opening WordPress in your browser to authorize Loopress...',
|
|
37
|
+
timeoutMessage: 'Authorization timed out after 5 minutes. You can enter credentials manually instead.',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const SUCCESS_PAGE = renderResultPage({
|
|
41
|
+
background: '#f0fdf4',
|
|
42
|
+
heading: 'Authorization successful!',
|
|
43
|
+
headingColor: '#15803d',
|
|
44
|
+
icon: '✅',
|
|
45
|
+
tabTitle: 'Authorized',
|
|
46
|
+
});
|
|
47
|
+
const REJECTED_PAGE = renderResultPage({
|
|
48
|
+
background: '#fef2f2',
|
|
49
|
+
heading: 'Authorization rejected',
|
|
50
|
+
headingColor: '#b91c1c',
|
|
51
|
+
icon: '✕',
|
|
52
|
+
tabTitle: 'Authorization rejected',
|
|
53
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const REQUEST_TIMEOUT_MS = 10000;
|
|
2
|
+
export type DiagnosticResult = {
|
|
3
|
+
ok: false;
|
|
4
|
+
reason: string;
|
|
5
|
+
} | {
|
|
6
|
+
ok: true;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Pre-flight checks run before starting the browser authorization flow, so failures
|
|
10
|
+
* (unreachable site, blocked REST API, WordPress too old) surface as an actionable
|
|
11
|
+
* message instead of a confusing timeout once the browser is already open.
|
|
12
|
+
*/
|
|
13
|
+
export declare function diagnoseWpSite(siteUrl: string): Promise<DiagnosticResult>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import got from 'got';
|
|
2
|
+
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
3
|
+
/**
|
|
4
|
+
* Pre-flight checks run before starting the browser authorization flow, so failures
|
|
5
|
+
* (unreachable site, blocked REST API, WordPress too old) surface as an actionable
|
|
6
|
+
* message instead of a confusing timeout once the browser is already open.
|
|
7
|
+
*/
|
|
8
|
+
export async function diagnoseWpSite(siteUrl) {
|
|
9
|
+
try {
|
|
10
|
+
await got.get(`${siteUrl}/wp-json/`, { timeout: { request: REQUEST_TIMEOUT_MS } });
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
reason: `Could not reach the WordPress REST API at ${siteUrl}/wp-json/. The site may be unreachable, or a security plugin may be blocking it. (${describe(error)})`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const response = await got.head(`${siteUrl}/wp-admin/authorize-application.php`, {
|
|
20
|
+
throwHttpErrors: false,
|
|
21
|
+
timeout: { request: REQUEST_TIMEOUT_MS },
|
|
22
|
+
});
|
|
23
|
+
if (response.statusCode === 404) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
reason: `The application authorization page was not found on ${siteUrl}. This WordPress site may be older than 5.6, or the feature may be disabled by a plugin.`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (response.statusCode >= 400) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
reason: `The application authorization page at ${siteUrl}/wp-admin/authorize-application.php returned an error (HTTP ${response.statusCode}).`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
reason: `Could not reach ${siteUrl}/wp-admin/authorize-application.php. (${describe(error)})`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { ok: true };
|
|
43
|
+
}
|
|
44
|
+
function describe(error) {
|
|
45
|
+
return error instanceof Error ? error.message : String(error);
|
|
46
|
+
}
|
package/oclif.manifest.json
CHANGED
|
@@ -351,6 +351,36 @@
|
|
|
351
351
|
"list.js"
|
|
352
352
|
]
|
|
353
353
|
},
|
|
354
|
+
"snippet:publish": {
|
|
355
|
+
"aliases": [],
|
|
356
|
+
"args": {
|
|
357
|
+
"path": {
|
|
358
|
+
"description": "Path to snippets directory (overrides project config)",
|
|
359
|
+
"name": "path"
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
"description": "Publish snippets to your Loopress account so they can be deployed to other projects. Does not touch any WordPress site.",
|
|
363
|
+
"examples": [
|
|
364
|
+
"$ lps snippet publish",
|
|
365
|
+
"$ lps snippet publish --path ./snippets"
|
|
366
|
+
],
|
|
367
|
+
"flags": {},
|
|
368
|
+
"hasDynamicHelp": false,
|
|
369
|
+
"hiddenAliases": [],
|
|
370
|
+
"id": "snippet:publish",
|
|
371
|
+
"pluginAlias": "@loopress/cli",
|
|
372
|
+
"pluginName": "@loopress/cli",
|
|
373
|
+
"pluginType": "core",
|
|
374
|
+
"strict": true,
|
|
375
|
+
"enableJsonFlag": false,
|
|
376
|
+
"isESM": true,
|
|
377
|
+
"relativePath": [
|
|
378
|
+
"dist",
|
|
379
|
+
"commands",
|
|
380
|
+
"snippet",
|
|
381
|
+
"publish.js"
|
|
382
|
+
]
|
|
383
|
+
},
|
|
354
384
|
"snippet:pull": {
|
|
355
385
|
"aliases": [],
|
|
356
386
|
"args": {
|
|
@@ -437,7 +467,7 @@
|
|
|
437
467
|
"project:config": {
|
|
438
468
|
"aliases": [],
|
|
439
469
|
"args": {},
|
|
440
|
-
"description": "Add or update a WordPress project environment",
|
|
470
|
+
"description": "Add or update a WordPress project environment. By default, authorizes via WordPress in your browser; manual username/Application Password entry is available as a fallback.",
|
|
441
471
|
"examples": [
|
|
442
472
|
"$ lps project config"
|
|
443
473
|
],
|
|
@@ -555,5 +585,5 @@
|
|
|
555
585
|
]
|
|
556
586
|
}
|
|
557
587
|
},
|
|
558
|
-
"version": "0.
|
|
588
|
+
"version": "0.13.0"
|
|
559
589
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loopress/cli",
|
|
3
3
|
"description": "CLI tool for syncing WordPress code snippets, plugins, and Composer dependencies via the REST API",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.13.0",
|
|
5
5
|
"author": "jean-smaug",
|
|
6
6
|
"bin": {
|
|
7
7
|
"loopress": "bin/run.js",
|
|
@@ -109,6 +109,7 @@
|
|
|
109
109
|
"prewatch": "pnpm run schema:types",
|
|
110
110
|
"watch": "tsc -b --watch",
|
|
111
111
|
"build": "shx rm -rf dist && pnpm run schema:types && tsc -b",
|
|
112
|
+
"prelint": "pnpm run schema:types",
|
|
112
113
|
"lint": "eslint",
|
|
113
114
|
"pretest": "pnpm run schema:types",
|
|
114
115
|
"posttest": "pnpm run lint",
|