@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 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.12.0 linux-x64 node-v24.18.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.12.0/src/commands/composer/pull.ts)_
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.12.0/src/commands/composer/push.ts)_
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.12.0/src/commands/init.ts)_
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.12.0/src/commands/login.ts)_
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.12.0/src/commands/logout.ts)_
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.12.0/src/commands/plugin/add.ts)_
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.12.0/src/commands/plugin/pull.ts)_
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.12.0/src/commands/plugin/push.ts)_
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.12.0/src/commands/project/config.ts)_
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.12.0/src/commands/project/list.ts)_
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.12.0/src/commands/project/remove.ts)_
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.12.0/src/commands/project/switch.ts)_
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.12.0/src/commands/project/sync.ts)_
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.12.0/src/commands/snippet/list.ts)_
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.12.0/src/commands/snippet/pull.ts)_
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.12.0/src/commands/snippet/push.ts)_
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.12.0/src/commands/status.ts)_
451
+ _See code: [src/commands/status.ts](https://github.com/loopress/loopress/blob/v0.13.0/src/commands/status.ts)_
427
452
  <!-- commandsstop -->
@@ -3,6 +3,5 @@ export default class Login extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  run(): Promise<void>;
6
- private openBrowser;
7
6
  private waitForCallback;
8
7
  }
@@ -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 new Promise((resolve, reject) => {
27
- const server = createServer((req, res) => {
28
- try {
29
- const url = new URL(req.url ?? '/', 'http://localhost');
30
- const token = url.searchParams.get('token');
31
- const email = url.searchParams.get('email') ?? undefined;
32
- if (!token) {
33
- res.writeHead(400, { 'Content-Type': 'text/plain' });
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
- server.on('error', (err) => {
51
- clearTimeout(timer);
52
- reject(err);
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 = `<!DOCTYPE html>
70
- <html lang="en">
71
- <head>
72
- <meta charset="UTF-8">
73
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
74
- <title>Loopress: Logged in</title>
75
- <style>
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
+ });
@@ -3,5 +3,7 @@ export default class Config extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  run(): Promise<void>;
6
+ private promptManualCredentials;
7
+ private resolveCredentials;
6
8
  private resolveProject;
7
9
  }
@@ -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 url = await input({
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 user = await input({
53
- message: 'Username',
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 { readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
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 { defaultLocationForType, normalizeSnippet, parseInsertMethod, parseLocation, parseType, SNIPPETS_ENDPOINT, stripPhpOpeningTag, } from '../../utils/snippet-format.js';
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
- files = await readdir(path);
76
+ return await loadSnippetsFromDisk(path, (message) => this.warn(message));
85
77
  }
86
78
  catch (error) {
87
- this.error(`Error loading snippets: ${error.message}`);
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,2 @@
1
+ import { Snippet } from '../types/snippet.js';
2
+ export declare function loadSnippets(path: string, onSkip?: (message: string) => void): Promise<Snippet[]>;
@@ -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,2 @@
1
+ /** Opens `url` in the user's default browser, best-effort (no-op on unknown platforms). */
2
+ export declare function openBrowser(url: string): void;
@@ -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
+ }
@@ -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.12.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.12.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",