@loopress/cli 0.10.0 → 0.12.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.
Files changed (36) hide show
  1. package/README.md +41 -35
  2. package/dist/commands/init.js +24 -10
  3. package/dist/commands/project/config.js +1 -1
  4. package/dist/commands/project/remove.js +3 -2
  5. package/dist/commands/project/switch.d.ts +1 -2
  6. package/dist/commands/project/switch.js +28 -34
  7. package/dist/commands/project/sync.d.ts +13 -0
  8. package/dist/commands/project/sync.js +220 -0
  9. package/dist/commands/snippet/list.d.ts +0 -1
  10. package/dist/commands/snippet/list.js +4 -7
  11. package/dist/commands/snippet/pull.d.ts +2 -2
  12. package/dist/commands/snippet/pull.js +44 -14
  13. package/dist/commands/snippet/push.d.ts +1 -1
  14. package/dist/commands/snippet/push.js +56 -26
  15. package/dist/config/project-config.manager.d.ts +6 -1
  16. package/dist/config/project-config.manager.js +39 -3
  17. package/dist/help.js +1 -1
  18. package/dist/lib/api-client.d.ts +14 -0
  19. package/dist/lib/api-client.js +46 -0
  20. package/dist/lib/base.d.ts +0 -1
  21. package/dist/lib/base.js +0 -5
  22. package/dist/lib/push-command.js +1 -1
  23. package/dist/lib/wp-client.d.ts +1 -0
  24. package/dist/lib/wp-client.js +22 -1
  25. package/dist/types/global-config.generated.d.ts +8 -0
  26. package/dist/types/project-config.generated.d.ts +0 -4
  27. package/dist/types/snippet.d.ts +1 -1
  28. package/dist/utils/{snippet-plugin.d.ts → snippet-format.d.ts} +3 -19
  29. package/dist/utils/snippet-format.js +57 -0
  30. package/oclif.manifest.json +124 -139
  31. package/package.json +6 -1
  32. package/schemas/global-config.schema.json +8 -0
  33. package/schemas/project-config.schema.json +0 -6
  34. package/dist/utils/snippet-plugin-flag.d.ts +0 -3
  35. package/dist/utils/snippet-plugin-flag.js +0 -8
  36. package/dist/utils/snippet-plugin.js +0 -232
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.10.0 linux-x64 node-v24.18.0
24
+ @loopress/cli/0.12.0 linux-x64 node-v24.18.0
25
25
  $ lps --help [COMMAND]
26
26
  USAGE
27
27
  $ lps COMMAND
@@ -59,6 +59,7 @@ report.
59
59
  * [`lps project list`](#lps-project-list)
60
60
  * [`lps project remove`](#lps-project-remove)
61
61
  * [`lps project switch`](#lps-project-switch)
62
+ * [`lps project sync`](#lps-project-sync)
62
63
  * [`lps snippet list`](#lps-snippet-list)
63
64
  * [`lps snippet pull [PATH]`](#lps-snippet-pull-path)
64
65
  * [`lps snippet push [PATH]`](#lps-snippet-push-path)
@@ -84,7 +85,7 @@ EXAMPLES
84
85
  $ lps composer pull --dry-run
85
86
  ```
86
87
 
87
- _See code: [src/commands/composer/pull.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/composer/pull.ts)_
88
+ _See code: [src/commands/composer/pull.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/composer/pull.ts)_
88
89
 
89
90
  ## `lps composer push`
90
91
 
@@ -106,7 +107,7 @@ EXAMPLES
106
107
  $ lps composer push --dry-run
107
108
  ```
108
109
 
109
- _See code: [src/commands/composer/push.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/composer/push.ts)_
110
+ _See code: [src/commands/composer/push.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/composer/push.ts)_
110
111
 
111
112
  ## `lps help [COMMAND]`
112
113
 
@@ -143,7 +144,7 @@ EXAMPLES
143
144
  $ lps init
144
145
  ```
145
146
 
146
- _See code: [src/commands/init.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/init.ts)_
147
+ _See code: [src/commands/init.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/init.ts)_
147
148
 
148
149
  ## `lps login`
149
150
 
@@ -160,7 +161,7 @@ EXAMPLES
160
161
  $ lps login
161
162
  ```
162
163
 
163
- _See code: [src/commands/login.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/login.ts)_
164
+ _See code: [src/commands/login.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/login.ts)_
164
165
 
165
166
  ## `lps logout`
166
167
 
@@ -177,7 +178,7 @@ EXAMPLES
177
178
  $ lps logout
178
179
  ```
179
180
 
180
- _See code: [src/commands/logout.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/logout.ts)_
181
+ _See code: [src/commands/logout.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/logout.ts)_
181
182
 
182
183
  ## `lps plugin add SLUG [VERSION]`
183
184
 
@@ -205,7 +206,7 @@ EXAMPLES
205
206
  $ lps plugin add contact-form-7 --dry-run
206
207
  ```
207
208
 
208
- _See code: [src/commands/plugin/add.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/plugin/add.ts)_
209
+ _See code: [src/commands/plugin/add.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/plugin/add.ts)_
209
210
 
210
211
  ## `lps plugin pull`
211
212
 
@@ -227,7 +228,7 @@ EXAMPLES
227
228
  $ lps plugin pull --dry-run
228
229
  ```
229
230
 
230
- _See code: [src/commands/plugin/pull.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/plugin/pull.ts)_
231
+ _See code: [src/commands/plugin/pull.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/plugin/pull.ts)_
231
232
 
232
233
  ## `lps plugin push`
233
234
 
@@ -249,7 +250,7 @@ EXAMPLES
249
250
  $ lps plugin push --dry-run
250
251
  ```
251
252
 
252
- _See code: [src/commands/plugin/push.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/plugin/push.ts)_
253
+ _See code: [src/commands/plugin/push.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/plugin/push.ts)_
253
254
 
254
255
  ## `lps project config`
255
256
 
@@ -266,7 +267,7 @@ EXAMPLES
266
267
  $ lps project config
267
268
  ```
268
269
 
269
- _See code: [src/commands/project/config.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/project/config.ts)_
270
+ _See code: [src/commands/project/config.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/project/config.ts)_
270
271
 
271
272
  ## `lps project list`
272
273
 
@@ -283,7 +284,7 @@ EXAMPLES
283
284
  $ lps project list
284
285
  ```
285
286
 
286
- _See code: [src/commands/project/list.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/project/list.ts)_
287
+ _See code: [src/commands/project/list.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/project/list.ts)_
287
288
 
288
289
  ## `lps project remove`
289
290
 
@@ -300,7 +301,7 @@ EXAMPLES
300
301
  $ lps project remove
301
302
  ```
302
303
 
303
- _See code: [src/commands/project/remove.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/project/remove.ts)_
304
+ _See code: [src/commands/project/remove.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/project/remove.ts)_
304
305
 
305
306
  ## `lps project switch`
306
307
 
@@ -317,7 +318,24 @@ EXAMPLES
317
318
  $ lps project switch
318
319
  ```
319
320
 
320
- _See code: [src/commands/project/switch.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/project/switch.ts)_
321
+ _See code: [src/commands/project/switch.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/project/switch.ts)_
322
+
323
+ ## `lps project sync`
324
+
325
+ Sync locally configured projects and environments with your Loopress account
326
+
327
+ ```
328
+ USAGE
329
+ $ lps project sync
330
+
331
+ DESCRIPTION
332
+ Sync locally configured projects and environments with your Loopress account
333
+
334
+ EXAMPLES
335
+ $ lps project sync
336
+ ```
337
+
338
+ _See code: [src/commands/project/sync.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/project/sync.ts)_
321
339
 
322
340
  ## `lps snippet list`
323
341
 
@@ -325,23 +343,19 @@ List snippets from WordPress
325
343
 
326
344
  ```
327
345
  USAGE
328
- $ lps snippet list [-j] [-p code-snippets|wpcode]
346
+ $ lps snippet list [-j]
329
347
 
330
348
  FLAGS
331
- -j, --json Output in JSON format
332
- -p, --plugin=<option> WordPress snippet plugin to target (overrides loopress.json)
333
- <options: code-snippets|wpcode>
349
+ -j, --json Output in JSON format
334
350
 
335
351
  DESCRIPTION
336
352
  List snippets from WordPress
337
353
 
338
354
  EXAMPLES
339
355
  $ lps snippet list
340
-
341
- $ lps snippet list --plugin wpcode
342
356
  ```
343
357
 
344
- _See code: [src/commands/snippet/list.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/snippet/list.ts)_
358
+ _See code: [src/commands/snippet/list.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/snippet/list.ts)_
345
359
 
346
360
  ## `lps snippet pull [PATH]`
347
361
 
@@ -349,15 +363,13 @@ Pull snippets from WordPress
349
363
 
350
364
  ```
351
365
  USAGE
352
- $ lps snippet pull [PATH] [-d] [-p code-snippets|wpcode]
366
+ $ lps snippet pull [PATH] [-d]
353
367
 
354
368
  ARGUMENTS
355
369
  [PATH] Path to snippets directory (overrides project config)
356
370
 
357
371
  FLAGS
358
- -d, --dry-run Show what would change without making changes
359
- -p, --plugin=<option> WordPress snippet plugin to target (overrides loopress.json)
360
- <options: code-snippets|wpcode>
372
+ -d, --dry-run Show what would change without making changes
361
373
 
362
374
  DESCRIPTION
363
375
  Pull snippets from WordPress
@@ -366,11 +378,9 @@ EXAMPLES
366
378
  $ lps snippet pull
367
379
 
368
380
  $ lps snippet pull --path ./snippets
369
-
370
- $ lps snippet pull --plugin wpcode
371
381
  ```
372
382
 
373
- _See code: [src/commands/snippet/pull.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/snippet/pull.ts)_
383
+ _See code: [src/commands/snippet/pull.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/snippet/pull.ts)_
374
384
 
375
385
  ## `lps snippet push [PATH]`
376
386
 
@@ -378,15 +388,13 @@ Push snippets to WordPress. Local snippet files created or updated remotely are
378
388
 
379
389
  ```
380
390
  USAGE
381
- $ lps snippet push [PATH] [-d] [-p code-snippets|wpcode]
391
+ $ lps snippet push [PATH] [-d]
382
392
 
383
393
  ARGUMENTS
384
394
  [PATH] Path to snippets directory (overrides project config)
385
395
 
386
396
  FLAGS
387
- -d, --dry-run Show what would change without making changes
388
- -p, --plugin=<option> WordPress snippet plugin to target (overrides loopress.json)
389
- <options: code-snippets|wpcode>
397
+ -d, --dry-run Show what would change without making changes
390
398
 
391
399
  DESCRIPTION
392
400
  Push snippets to WordPress. Local snippet files created or updated remotely are renamed on disk to the `<id>-<slug>`
@@ -396,11 +404,9 @@ EXAMPLES
396
404
  $ lps snippet push
397
405
 
398
406
  $ lps snippet push --path ./snippets
399
-
400
- $ lps snippet push --plugin wpcode
401
407
  ```
402
408
 
403
- _See code: [src/commands/snippet/push.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/snippet/push.ts)_
409
+ _See code: [src/commands/snippet/push.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/snippet/push.ts)_
404
410
 
405
411
  ## `lps status`
406
412
 
@@ -417,5 +423,5 @@ EXAMPLES
417
423
  $ lps status
418
424
  ```
419
425
 
420
- _See code: [src/commands/status.ts](https://github.com/loopress/loopress/blob/v0.10.0/src/commands/status.ts)_
426
+ _See code: [src/commands/status.ts](https://github.com/loopress/loopress/blob/v0.12.0/src/commands/status.ts)_
421
427
  <!-- commandsstop -->
@@ -3,7 +3,13 @@ import { Command } from '@oclif/core';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { configManager } from '../config/project-config.manager.js';
6
- import { writeLocalConfig } from '../utils/loopress-config.js';
6
+ import { readLocalConfig, writeLocalConfig } from '../utils/loopress-config.js';
7
+ // WordPress.org slugs for the two snippet plugins the Loopress WordPress plugin supports
8
+ // (see SnippetModule.php, which wires up both providers and auto-detects the active one).
9
+ const SNIPPET_PROVIDERS = [
10
+ { name: 'Code Snippets', slug: 'code-snippets' },
11
+ { name: 'WPCode', slug: 'insert-headers-and-footers' },
12
+ ];
7
13
  export default class Init extends Command {
8
14
  static description = 'Initialize a loopress.json config file in the current directory';
9
15
  static examples = ['$ lps init'];
@@ -52,13 +58,6 @@ export default class Init extends Command {
52
58
  });
53
59
  projectLabel = projectId;
54
60
  }
55
- const snippetPlugin = await select({
56
- choices: [
57
- { name: 'WPCode', value: 'wpcode' },
58
- { name: 'Code Snippets', value: 'code-snippets' },
59
- ],
60
- message: 'Snippet plugin',
61
- });
62
61
  const rootDir = await input({
63
62
  default: '.',
64
63
  message: 'Root directory',
@@ -67,16 +66,31 @@ export default class Init extends Command {
67
66
  default: 'snippets',
68
67
  message: 'Snippets directory (relative to root)',
69
68
  });
69
+ const providerChoice = await select({
70
+ choices: [...SNIPPET_PROVIDERS.map((p) => ({ name: p.name, value: p.slug })), { name: 'None / already installed', value: '__none__' }],
71
+ message: 'Snippet provider',
72
+ });
70
73
  const config = {
71
74
  projectId,
72
75
  rootDir,
73
- snippetPlugin: snippetPlugin,
74
76
  snippetsDir,
75
77
  };
76
78
  await writeLocalConfig(config);
79
+ let providerVersion;
80
+ if (providerChoice !== '__none__') {
81
+ try {
82
+ await this.config.runCommand('plugin:add', [providerChoice]);
83
+ providerVersion = (await readLocalConfig()).plugins?.[providerChoice];
84
+ }
85
+ catch (error) {
86
+ this.warn(error.message);
87
+ }
88
+ }
77
89
  this.log(`\n✓ loopress.json created`);
78
90
  this.log(` Project: ${projectLabel}`);
79
- this.log(` Plugin: ${snippetPlugin}`);
80
91
  this.log(` Snippets: ${join(rootDir, snippetsDir)}`);
92
+ if (providerChoice !== '__none__' && providerVersion) {
93
+ this.log(` Plugin: ${providerChoice}@${providerVersion}`);
94
+ }
81
95
  }
82
96
  }
@@ -106,6 +106,6 @@ export default class Config extends Command {
106
106
  return true;
107
107
  },
108
108
  });
109
- return { projectId: configManager.createProjectId(), projectName: projectName.trim() };
109
+ return { projectId: configManager.createProjectId(projectName.trim()), projectName: projectName.trim() };
110
110
  }
111
111
  }
@@ -17,13 +17,14 @@ export default class Remove extends Command {
17
17
  const currentMarker = project.isCurrent ? ' [current]' : '';
18
18
  targets.push({ kind: 'project', projectId: project.id, projectName: project.name });
19
19
  const projectChoice = {
20
- name: `${project.isCurrent ? '●' : '○'} ${project.name.padEnd(20)} (${envLabel})${currentMarker}`,
20
+ description: 'Also removes all its environments below.',
21
+ name: `${project.name.padEnd(20)} (${envLabel})${currentMarker}`,
21
22
  value: String(targets.length - 1),
22
23
  };
23
24
  const envChoices = configManager.listEnvironments(project.id).map((env) => {
24
25
  targets.push({ env: env.name, kind: 'env', projectId: project.id, projectName: project.name });
25
26
  return {
26
- name: ` ${env.isCurrent ? '●' : '○'} ${env.name.padEnd(20)} ${env.url}${env.isCurrent ? ' [current]' : ''}`,
27
+ name: ` ${env.name.padEnd(20)} ${env.url}${env.isCurrent ? ' [current]' : ''}`,
27
28
  value: String(targets.length - 1),
28
29
  };
29
30
  });
@@ -3,6 +3,5 @@ export default class Switch extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  run(): Promise<void>;
6
- private resolveEnvironment;
7
- private resolveProject;
6
+ private resolveSelection;
8
7
  }
@@ -1,6 +1,7 @@
1
- import { select } from '@inquirer/prompts';
2
- import { Command } from '@oclif/core';
1
+ import { select, Separator } from '@inquirer/prompts';
2
+ import { Command, ux } from '@oclif/core';
3
3
  import { configManager } from '../../config/project-config.manager.js';
4
+ const c = ux.colorize;
4
5
  export default class Switch extends Command {
5
6
  static description = 'Switch the active project and environment';
6
7
  static examples = ['$ lps project switch'];
@@ -10,44 +11,37 @@ export default class Switch extends Command {
10
11
  if (projects.length === 0) {
11
12
  this.error('No projects configured. Run `lps project config` first.');
12
13
  }
13
- const { id: projectId, name: projectName } = await this.resolveProject(projects);
14
- const envName = await this.resolveEnvironment(projectId, projectName);
14
+ const { envName, projectId, projectName } = await this.resolveSelection(projects);
15
15
  configManager.setCurrent(projectId, envName);
16
16
  this.log(`✓ Switched to "${projectName}/${envName}"`);
17
17
  }
18
- async resolveEnvironment(projectId, projectName) {
19
- const envs = configManager.listEnvironments(projectId);
20
- if (envs.length === 0) {
21
- this.error(`No environments configured for "${projectName}". Run \`lps project config\` first.`);
18
+ async resolveSelection(projects) {
19
+ const groups = projects
20
+ .map((project) => ({ envs: configManager.listEnvironments(project.id), project }))
21
+ .filter(({ envs }) => envs.length > 0);
22
+ if (groups.length === 0) {
23
+ this.error('No environments configured. Run `lps project config` first.');
22
24
  }
23
- if (envs.length === 1)
24
- return envs[0].name;
25
- return select({
26
- choices: envs.map((env) => ({
27
- name: `${env.isCurrent ? '●' : '○'} ${env.name.padEnd(20)} ${env.url}${env.isCurrent ? ' [current]' : ''}`,
28
- value: env.name,
25
+ const entries = groups.flatMap(({ envs, project }) => envs.map((env) => ({ env, project })));
26
+ if (entries.length === 1) {
27
+ const { env, project } = entries[0];
28
+ return { envName: env.name, projectId: project.id, projectName: project.name };
29
+ }
30
+ const choices = groups.flatMap(({ envs, project }) => [
31
+ new Separator(project.isCurrent ? c('green', `─── ${project.name} ───`) : c('dim', `─── ${project.name} ───`)),
32
+ ...envs.map((env) => ({
33
+ name: `${env.name.padEnd(20)} ${env.url}${env.isCurrent ? ' [current]' : ''}`,
34
+ value: `${project.id}::${env.name}`,
29
35
  })),
30
- default: envs.find((env) => env.isCurrent)?.name,
31
- message: `Select environment for "${projectName}"`,
32
- });
33
- }
34
- async resolveProject(projects) {
35
- if (projects.length === 1)
36
- return { id: projects[0].id, name: projects[0].name };
36
+ ]);
37
+ const current = entries.find(({ env }) => env.isCurrent);
37
38
  const chosen = await select({
38
- choices: projects.map((project) => {
39
- const envCount = Object.keys(project.environments).length;
40
- const envLabel = `${envCount} env${envCount > 1 ? 's' : ''}`;
41
- const currentMarker = project.isCurrent ? ' [current]' : '';
42
- return {
43
- name: `${project.isCurrent ? '●' : '○'} ${project.name.padEnd(20)} (${envLabel})${currentMarker}`,
44
- value: project.id,
45
- };
46
- }),
47
- default: projects.find((project) => project.isCurrent)?.id,
48
- message: 'Select active project',
39
+ choices,
40
+ default: current && `${current.project.id}::${current.env.name}`,
41
+ message: 'Select project / environment',
49
42
  });
50
- const project = projects.find((p) => p.id === chosen);
51
- return { id: project.id, name: project.name };
43
+ const [projectId, envName] = chosen.split('::');
44
+ const { project } = groups.find((group) => group.project.id === projectId);
45
+ return { envName, projectId, projectName: project.name };
52
46
  }
53
47
  }
@@ -0,0 +1,13 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Sync extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ run(): Promise<void>;
6
+ private applyEnvironment;
7
+ private applyProject;
8
+ private fetchApiProjects;
9
+ private planEnvironment;
10
+ private planProject;
11
+ private pullProject;
12
+ private pushCredentials;
13
+ }
@@ -0,0 +1,220 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Command } from '@oclif/core';
3
+ import { Listr } from 'listr2';
4
+ import slugify from 'slugify';
5
+ import { authManager } from '../../config/auth.manager.js';
6
+ import { configManager } from '../../config/project-config.manager.js';
7
+ import { ApiClient } from '../../lib/api-client.js';
8
+ export default class Sync extends Command {
9
+ static description = 'Sync locally configured projects and environments with your Loopress account';
10
+ static examples = ['$ lps project sync'];
11
+ async run() {
12
+ await this.parse(Sync);
13
+ const token = authManager.getAuth()?.token;
14
+ if (!token) {
15
+ this.error('Not logged in. Run `lps login` first.');
16
+ }
17
+ const projects = configManager.listProjects();
18
+ if (projects.length === 0) {
19
+ this.log('No projects configured. Run `lps project config` first.');
20
+ return;
21
+ }
22
+ const api = new ApiClient(token);
23
+ const apiProjects = await this.fetchApiProjects(api);
24
+ const claimedProjectIds = new Set();
25
+ const envPlansByProject = new Map();
26
+ const projectPlans = [];
27
+ for (const project of projects) {
28
+ const plan = await this.planProject(project, apiProjects, claimedProjectIds);
29
+ projectPlans.push(plan);
30
+ const apiProject = plan.apiProjectId ? apiProjects.find((candidate) => candidate.id === plan.apiProjectId) : undefined;
31
+ const claimedEnvironmentIds = new Set();
32
+ const envPlans = [];
33
+ for (const env of configManager.listEnvironments(project.id)) {
34
+ envPlans.push(plan.action === 'create'
35
+ ? { action: 'create', env, projectId: project.id }
36
+ : await this.planEnvironment(env, project.id, apiProject, claimedEnvironmentIds));
37
+ }
38
+ envPlansByProject.set(project.id, envPlans);
39
+ }
40
+ let projectCount = projectPlans.filter((plan) => plan.action === 'synced').length;
41
+ let environmentCount = [...envPlansByProject.values()].flat().filter((plan) => plan.action === 'synced').length;
42
+ const projectsNeedingWork = projectPlans.filter((plan) => plan.action !== 'synced');
43
+ if (projectsNeedingWork.length > 0) {
44
+ await new Listr(projectsNeedingWork.map((plan) => ({
45
+ task: async (_ctx, task) => {
46
+ await this.applyProject(api, plan, task);
47
+ projectCount++;
48
+ },
49
+ title: plan.action === 'create'
50
+ ? `Create project "${plan.project.name}" on the API`
51
+ : `Link project "${plan.project.name}" to the API`,
52
+ })), { concurrent: false, exitOnError: false }).run();
53
+ }
54
+ const envsNeedingWork = projectPlans
55
+ .filter((plan) => Boolean(plan.apiProjectId))
56
+ .flatMap((plan) => (envPlansByProject.get(plan.project.id) ?? [])
57
+ .filter((envPlan) => envPlan.action !== 'synced')
58
+ .map((envPlan) => ({ apiProjectId: plan.apiProjectId, envPlan, projectName: plan.project.name })));
59
+ if (envsNeedingWork.length > 0) {
60
+ await new Listr(envsNeedingWork.map(({ apiProjectId, envPlan, projectName }) => ({
61
+ task: async (_ctx, task) => {
62
+ await this.applyEnvironment(api, apiProjectId, envPlan, task);
63
+ environmentCount++;
64
+ },
65
+ title: envPlan.action === 'create'
66
+ ? `Create environment "${envPlan.env.name}" on "${projectName}"`
67
+ : `Link environment "${envPlan.env.name}" on "${projectName}"`,
68
+ })), { concurrent: false, exitOnError: false }).run();
69
+ }
70
+ for (const plan of projectPlans) {
71
+ if (!plan.apiProjectId)
72
+ continue;
73
+ for (const envPlan of envPlansByProject.get(plan.project.id) ?? []) {
74
+ if (!envPlan.apiEnvironmentId || !envPlan.env.token)
75
+ continue;
76
+ try {
77
+ await this.pushCredentials(api, plan.apiProjectId, envPlan.apiEnvironmentId, envPlan.env);
78
+ }
79
+ catch (error) {
80
+ this.warn(`Failed to sync "${plan.project.name}/${envPlan.env.name}": ${error.message}`);
81
+ }
82
+ }
83
+ }
84
+ const newApiProjects = apiProjects.filter((candidate) => !claimedProjectIds.has(candidate.id));
85
+ if (newApiProjects.length > 0) {
86
+ await new Listr(newApiProjects.map((apiProject) => ({
87
+ task: async (_ctx, task) => {
88
+ this.pullProject(apiProject, task);
89
+ projectCount++;
90
+ environmentCount += apiProject.environments.length;
91
+ },
92
+ title: `Pull project "${apiProject.name}" from the API`,
93
+ })), { concurrent: false, exitOnError: false }).run();
94
+ }
95
+ this.log(`\n✓ Synced ${projectCount} project${projectCount === 1 ? '' : 's'}, ${environmentCount} environment${environmentCount === 1 ? '' : 's'} with your Loopress account`);
96
+ }
97
+ async applyEnvironment(api, apiProjectId, envPlan, task) {
98
+ try {
99
+ if (envPlan.action === 'create') {
100
+ const created = await api.post(`projects/${apiProjectId}/environments`, {
101
+ name: envPlan.env.name,
102
+ url: envPlan.env.url,
103
+ });
104
+ envPlan.apiEnvironmentId = created.id;
105
+ }
106
+ if (!envPlan.apiEnvironmentId) {
107
+ throw new Error(`No API environment id resolved for "${envPlan.env.name}" (action: ${envPlan.action})`);
108
+ }
109
+ configManager.setEnvironmentApiId(envPlan.projectId, envPlan.env.name, envPlan.apiEnvironmentId);
110
+ if (task)
111
+ task.output = envPlan.action === 'create' ? 'Created on the API' : 'Linked to the API';
112
+ }
113
+ catch (error) {
114
+ const message = `Failed to sync "${envPlan.env.name}": ${error.message}`;
115
+ if (task)
116
+ task.output = message;
117
+ throw error;
118
+ }
119
+ }
120
+ async applyProject(api, plan, task) {
121
+ try {
122
+ if (plan.action === 'create') {
123
+ const created = await api.post('projects', { name: plan.project.name });
124
+ plan.apiProjectId = created.id;
125
+ }
126
+ if (!plan.apiProjectId) {
127
+ throw new Error(`No API project id resolved for "${plan.project.name}" (action: ${plan.action})`);
128
+ }
129
+ configManager.setProjectApiId(plan.project.id, plan.apiProjectId);
130
+ if (task)
131
+ task.output = plan.action === 'create' ? 'Created on the API' : 'Linked to the API';
132
+ }
133
+ catch (error) {
134
+ const message = `Failed to sync project "${plan.project.name}": ${error.message}`;
135
+ if (task)
136
+ task.output = message;
137
+ throw error;
138
+ }
139
+ }
140
+ async fetchApiProjects(api) {
141
+ try {
142
+ return await api.get('projects');
143
+ }
144
+ catch (error) {
145
+ this.warn(`Could not fetch existing projects from the API, will create everything as new: ${error.message}`);
146
+ return [];
147
+ }
148
+ }
149
+ async planEnvironment(env, projectId, apiProject, claimedEnvironmentIds) {
150
+ if (env.apiEnvironmentId) {
151
+ claimedEnvironmentIds.add(env.apiEnvironmentId);
152
+ return { action: 'synced', apiEnvironmentId: env.apiEnvironmentId, env, projectId };
153
+ }
154
+ const match = apiProject?.environments.find((candidate) => candidate.name === env.name && !claimedEnvironmentIds.has(candidate.id));
155
+ if (match) {
156
+ const link = await confirm({
157
+ default: true,
158
+ message: `Environment "${env.name}" already exists on "${apiProject?.name}". Link to it instead of creating a new one?`,
159
+ });
160
+ if (link) {
161
+ claimedEnvironmentIds.add(match.id);
162
+ return { action: 'link', apiEnvironmentId: match.id, env, projectId };
163
+ }
164
+ }
165
+ return { action: 'create', env, projectId };
166
+ }
167
+ async planProject(project, apiProjects, claimedProjectIds) {
168
+ if (project.apiProjectId) {
169
+ claimedProjectIds.add(project.apiProjectId);
170
+ return { action: 'synced', apiProjectId: project.apiProjectId, project };
171
+ }
172
+ const slug = slugify(project.name, { lower: true, strict: true });
173
+ const match = apiProjects.find((candidate) => candidate.slug === slug && !claimedProjectIds.has(candidate.id));
174
+ if (match) {
175
+ const link = await confirm({
176
+ default: true,
177
+ message: `A project named "${project.name}" already exists on your account. Link to it instead of creating a new one?`,
178
+ });
179
+ if (link) {
180
+ claimedProjectIds.add(match.id);
181
+ return { action: 'link', apiProjectId: match.id, project };
182
+ }
183
+ }
184
+ return { action: 'create', project };
185
+ }
186
+ pullProject(apiProject, task) {
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 };
192
+ for (const env of apiProject.environments) {
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,
203
+ apiProjectId: apiProject.id,
204
+ environments,
205
+ name: apiProject.name,
206
+ });
207
+ const envCount = apiProject.environments.length;
208
+ if (task)
209
+ task.output = `Pulled with ${envCount} environment${envCount === 1 ? '' : 's'}`;
210
+ }
211
+ async pushCredentials(api, apiProjectId, apiEnvironmentId, env) {
212
+ if (!env.token)
213
+ return;
214
+ const [username, ...rest] = env.token.split(':');
215
+ await api.put(`projects/${apiProjectId}/environments/${apiEnvironmentId}/credentials`, {
216
+ password: rest.join(':'),
217
+ username,
218
+ });
219
+ }
220
+ }
@@ -3,7 +3,6 @@ export default class List extends LoopressCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
- plugin: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
6
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
7
  };
9
8
  run(): Promise<void>;