@loopress/cli 0.11.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.11.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.11.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.11.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.11.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.11.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.11.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.11.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.11.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.11.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.11.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.11.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.11.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.11.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.11.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.11.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.11.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.11.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.11.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) {
@@ -184,12 +184,22 @@ export default class Sync extends Command {
184
184
  return { action: 'create', project };
185
185
  }
186
186
  pullProject(apiProject, task) {
187
- const environments = {};
187
+ // Reuse a local project already linked to this API project instead of always minting a
188
+ // new one: otherwise every sync run that can't "claim" an existing link (e.g. after the
189
+ // local config was reset or desynced from the API) creates yet another duplicate entry.
190
+ const existing = configManager.findProjectByApiId(apiProject.id);
191
+ const environments = { ...existing?.environments };
188
192
  for (const env of apiProject.environments) {
189
- environments[env.name] = { addedAt: env.createdAt, apiEnvironmentId: env.id, name: env.name, url: env.url };
190
- }
191
- configManager.setProject(configManager.createProjectId(apiProject.name), {
192
- addedAt: apiProject.createdAt,
193
+ environments[env.name] = {
194
+ ...environments[env.name],
195
+ addedAt: environments[env.name]?.addedAt ?? env.createdAt,
196
+ apiEnvironmentId: env.id,
197
+ name: env.name,
198
+ url: env.url,
199
+ };
200
+ }
201
+ configManager.setProject(existing?.id ?? configManager.createProjectId(apiProject.name), {
202
+ addedAt: existing?.addedAt ?? apiProject.createdAt,
193
203
  apiProjectId: apiProject.id,
194
204
  environments,
195
205
  name: apiProject.name,
@@ -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
+ }
@@ -12,4 +12,5 @@ export default class Pull extends LoopressCommand {
12
12
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
13
  };
14
14
  run(): Promise<void>;
15
+ private findOrphanedFiles;
15
16
  }
@@ -1,7 +1,7 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { Listr } from 'listr2';
3
- import { mkdir, writeFile } from 'node:fs/promises';
4
- import { join } from 'node:path';
3
+ import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
4
+ import { extname, join } from 'node:path';
5
5
  import slugify from 'slugify';
6
6
  import { LoopressCommand } from '../../lib/base.js';
7
7
  import { normalizeSnippet, SNIPPETS_ENDPOINT } from '../../utils/snippet-format.js';
@@ -55,13 +55,20 @@ export default class Pull extends LoopressCommand {
55
55
  this.log(`Snippets path: ${path}`);
56
56
  const remoteList = await this.wp.get(SNIPPETS_ENDPOINT);
57
57
  const snippets = remoteList.map((r) => normalizeSnippet(r));
58
+ const pullable = snippets.filter((snippet) => snippet.name.trim());
59
+ const skipped = snippets.length - pullable.length;
60
+ // Files following the `<id>-<slug>` convention whose id is no longer in the current
61
+ // remote list belong to a snippet that was deleted on WordPress. Left on disk, they'd
62
+ // silently come back to life the next time `snippet push` runs.
63
+ const orphans = await this.findOrphanedFiles(path, new Set(pullable.map((snippet) => snippet.id)));
58
64
  if (this.dryRun) {
59
65
  this.log(`[dry-run] Would pull ${snippets.length} snippet${snippets.length === 1 ? '' : 's'} to ${path}`);
66
+ if (orphans.length > 0) {
67
+ this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} whose snippet no longer exists on WordPress: ${orphans.join(', ')}`);
68
+ }
60
69
  return;
61
70
  }
62
71
  await mkdir(path, { recursive: true });
63
- const pullable = snippets.filter((snippet) => snippet.name.trim());
64
- const skipped = snippets.length - pullable.length;
65
72
  await new Listr(pullable.map((snippet) => ({
66
73
  async task(_ctx, task) {
67
74
  const ext = EXTENSIONS[snippet.type];
@@ -73,9 +80,36 @@ export default class Pull extends LoopressCommand {
73
80
  },
74
81
  title: `Pull ${snippet.name}`,
75
82
  }))).run();
83
+ for (const file of orphans)
84
+ await rm(join(path, file), { force: true });
85
+ if (orphans.length > 0) {
86
+ this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} whose snippet no longer exists on WordPress: ${orphans.join(', ')}`);
87
+ }
76
88
  this.log(`Pulled ${pullable.length} snippet${pullable.length === 1 ? '' : 's'} to ${path}`);
77
89
  if (skipped > 0) {
78
90
  this.warn(`${skipped} snippet${skipped === 1 ? '' : 's'} skipped because they have no name`);
79
91
  }
80
92
  }
93
+ // Only ever matches files already following the `<id>-<slug>` convention that `snippet
94
+ // pull`/`push` themselves produce, so a hand-created file without a numeric prefix is
95
+ // never at risk of being picked up here.
96
+ async findOrphanedFiles(path, keepIds) {
97
+ let files;
98
+ try {
99
+ files = await readdir(path);
100
+ }
101
+ catch (error) {
102
+ if (error.code === 'ENOENT')
103
+ return [];
104
+ throw error;
105
+ }
106
+ const knownExtensions = new Set(['json', ...Object.values(EXTENSIONS)]);
107
+ return files.filter((file) => {
108
+ const ext = extname(file).slice(1);
109
+ if (!knownExtensions.has(ext))
110
+ return false;
111
+ const match = /^(\d+)-/.exec(file);
112
+ return match !== null && !keepIds.has(Number(match[1]));
113
+ });
114
+ }
81
115
  }