@loopress/cli 0.21.0 → 0.23.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 (103) hide show
  1. package/README.md +457 -67
  2. package/dist/commands/acf/list.d.ts +2 -2
  3. package/dist/commands/acf/list.js +2 -5
  4. package/dist/commands/acf/pull.js +9 -22
  5. package/dist/commands/acf/push.js +3 -6
  6. package/dist/commands/api/list.js +2 -1
  7. package/dist/commands/api/publish.js +6 -22
  8. package/dist/commands/api/pull.js +12 -17
  9. package/dist/commands/api/push.js +7 -10
  10. package/dist/commands/app/list.d.ts +17 -0
  11. package/dist/commands/app/list.js +35 -0
  12. package/dist/commands/app/pull.d.ts +27 -0
  13. package/dist/commands/app/pull.js +94 -0
  14. package/dist/commands/app/push.d.ts +28 -0
  15. package/dist/commands/app/push.js +130 -0
  16. package/dist/commands/app/remove.d.ts +20 -0
  17. package/dist/commands/app/remove.js +42 -0
  18. package/dist/commands/composer/push.d.ts +1 -0
  19. package/dist/commands/composer/push.js +43 -19
  20. package/dist/commands/dev.js +1 -1
  21. package/dist/commands/doctor.js +2 -1
  22. package/dist/commands/form/list.d.ts +2 -4
  23. package/dist/commands/form/list.js +3 -10
  24. package/dist/commands/form/pull.js +15 -24
  25. package/dist/commands/form/push.js +17 -42
  26. package/dist/commands/init.js +1 -0
  27. package/dist/commands/login.js +6 -2
  28. package/dist/commands/logout.js +2 -1
  29. package/dist/commands/page/pull.js +17 -28
  30. package/dist/commands/page/push.js +27 -52
  31. package/dist/commands/plugin/add.d.ts +1 -0
  32. package/dist/commands/plugin/add.js +18 -8
  33. package/dist/commands/plugin/audit.d.ts +30 -0
  34. package/dist/commands/plugin/audit.js +138 -0
  35. package/dist/commands/plugin/pull.d.ts +1 -1
  36. package/dist/commands/plugin/pull.js +13 -15
  37. package/dist/commands/plugin/push.d.ts +14 -5
  38. package/dist/commands/plugin/push.js +171 -60
  39. package/dist/commands/plugin/status.d.ts +22 -0
  40. package/dist/commands/plugin/status.js +65 -0
  41. package/dist/commands/project/config.js +4 -1
  42. package/dist/commands/project/list.d.ts +16 -1
  43. package/dist/commands/project/list.js +22 -16
  44. package/dist/commands/project/pull.js +3 -2
  45. package/dist/commands/project/push.d.ts +5 -0
  46. package/dist/commands/project/push.js +79 -58
  47. package/dist/commands/promote.d.ts +18 -0
  48. package/dist/commands/promote.js +90 -0
  49. package/dist/commands/pull.d.ts +12 -0
  50. package/dist/commands/pull.js +51 -0
  51. package/dist/commands/push.d.ts +13 -0
  52. package/dist/commands/push.js +57 -0
  53. package/dist/commands/seo/list.d.ts +9 -2
  54. package/dist/commands/seo/list.js +9 -11
  55. package/dist/commands/seo/pull.js +14 -37
  56. package/dist/commands/seo/push.js +24 -48
  57. package/dist/commands/snippet/list.js +2 -1
  58. package/dist/commands/snippet/publish.js +5 -21
  59. package/dist/commands/snippet/pull.js +15 -20
  60. package/dist/commands/snippet/push.js +21 -30
  61. package/dist/commands/theme/add.d.ts +13 -0
  62. package/dist/commands/theme/add.js +35 -0
  63. package/dist/commands/theme/pull.d.ts +21 -0
  64. package/dist/commands/theme/pull.js +37 -0
  65. package/dist/commands/theme/push.d.ts +23 -0
  66. package/dist/commands/theme/push.js +130 -0
  67. package/dist/commands/theme/status.d.ts +20 -0
  68. package/dist/commands/theme/status.js +51 -0
  69. package/dist/commands/validate.d.ts +8 -0
  70. package/dist/commands/validate.js +24 -0
  71. package/dist/lib/app-manifest.d.ts +36 -0
  72. package/dist/lib/app-manifest.js +162 -0
  73. package/dist/lib/base.d.ts +10 -0
  74. package/dist/lib/base.js +56 -22
  75. package/dist/lib/guard-production-push.d.ts +12 -0
  76. package/dist/lib/guard-production-push.js +20 -0
  77. package/dist/lib/load-snippets.js +65 -60
  78. package/dist/lib/local-callback-server.js +9 -4
  79. package/dist/lib/push-command.d.ts +3 -0
  80. package/dist/lib/push-command.js +22 -15
  81. package/dist/lib/put-or-create.d.ts +13 -0
  82. package/dist/lib/put-or-create.js +19 -0
  83. package/dist/lib/readdir-tolerant.d.ts +5 -0
  84. package/dist/lib/readdir-tolerant.js +11 -0
  85. package/dist/lib/resolve-linked-project.d.ts +8 -0
  86. package/dist/lib/resolve-linked-project.js +27 -0
  87. package/dist/lib/validate-local.d.ts +10 -0
  88. package/dist/lib/validate-local.js +183 -0
  89. package/dist/types/plugin.d.ts +1 -0
  90. package/dist/types/project-config.generated.d.ts +11 -1
  91. package/dist/utils/plugin-sync.d.ts +26 -0
  92. package/dist/utils/plugin-sync.js +31 -0
  93. package/dist/utils/plugins.d.ts +15 -2
  94. package/dist/utils/plugins.js +57 -12
  95. package/dist/utils/pluralize.d.ts +1 -0
  96. package/dist/utils/pluralize.js +5 -0
  97. package/dist/utils/themes.d.ts +30 -0
  98. package/dist/utils/themes.js +29 -0
  99. package/dist/utils/version.d.ts +2 -0
  100. package/dist/utils/version.js +21 -0
  101. package/oclif.manifest.json +986 -125
  102. package/package.json +25 -20
  103. package/schemas/project-config.schema.json +22 -4
@@ -3,9 +3,9 @@ import { LoopressCommand } from '../../lib/base.js';
3
3
  import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, seoPostMetaEndpoint } from '../../utils/seo-format.js';
4
4
  export default class List extends LoopressCommand {
5
5
  static description = 'List posts with SEO meta, and redirects if supported by the active SEO plugin, on WordPress';
6
+ static enableJsonFlag = true;
6
7
  static examples = ['$ lps seo list', '$ lps seo list --post-type post'];
7
8
  static flags = {
8
- json: Flags.boolean({ char: 'j', description: 'Output in JSON format' }),
9
9
  'post-type': Flags.string({ description: 'Limit to specific post types', multiple: true }),
10
10
  };
11
11
  async run() {
@@ -16,10 +16,6 @@ export default class List extends LoopressCommand {
16
16
  byType[postType] = await this.wp.get(seoPostMetaEndpoint(postType));
17
17
  }
18
18
  const { redirects, unsupportedReason } = await this.fetchRedirects();
19
- if (flags.json) {
20
- this.log(JSON.stringify({ postMeta: byType, redirects: redirects ?? undefined, redirectsUnsupported: unsupportedReason }, null, 2));
21
- return;
22
- }
23
19
  for (const postType of postTypes) {
24
20
  const posts = byType[postType];
25
21
  this.log(`${postType} (${posts.length}):`);
@@ -35,16 +31,18 @@ export default class List extends LoopressCommand {
35
31
  }
36
32
  if (unsupportedReason) {
37
33
  this.log(`redirects: ${unsupportedReason}`);
38
- return;
34
+ return { postMeta: byType, redirects: undefined, redirectsUnsupported: unsupportedReason };
39
35
  }
40
36
  this.log(`redirects (${redirects.length}):`);
41
- if (redirects.length === 0) {
42
- this.log(' (none)');
43
- return;
37
+ if (redirects.length > 0) {
38
+ for (const redirect of redirects) {
39
+ this.log(` ${redirect.id}. [${redirect.status}] ${redirect.headerCode} -> ${redirect.urlTo}`);
40
+ }
44
41
  }
45
- for (const redirect of redirects) {
46
- this.log(` ${redirect.id}. [${redirect.status}] ${redirect.headerCode} -> ${redirect.urlTo}`);
42
+ else {
43
+ this.log(' (none)');
47
44
  }
45
+ return { postMeta: byType, redirects: redirects, redirectsUnsupported: undefined };
48
46
  }
49
47
  // Redirects are only supported by some SeoProvider backends (RankMath, not Yoast); reported
50
48
  // as a line in the listing rather than failing the whole command, since post meta above may
@@ -1,5 +1,4 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
- import { Listr } from 'listr2';
3
2
  import { mkdir, writeFile } from 'node:fs/promises';
4
3
  import { join } from 'node:path';
5
4
  import { LoopressCommand } from '../../lib/base.js';
@@ -36,24 +35,13 @@ export default class Pull extends LoopressCommand {
36
35
  extensions: ['.json'],
37
36
  key: basenameKey,
38
37
  });
39
- if (this.dryRun) {
40
- this.log(`[dry-run] Would pull ${remote.length} ${postType} post-meta file(s) to ${dir}`);
41
- if (orphans.length > 0) {
42
- this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${dir} no longer present on WordPress: ${orphans.join(', ')}`);
43
- }
44
- return;
45
- }
46
- if (remote.length > 0)
47
- await mkdir(dir, { recursive: true });
48
- await new Listr(remote.map((post) => ({
49
- async task(_ctx, task) {
50
- await writeFile(join(dir, `${post.slug}.json`), JSON.stringify(post, null, 2) + '\n');
51
- task.output = `Pulled: ${post.slug}`;
52
- },
53
- title: `Pull ${post.slug}`,
54
- }))).run();
55
- await this.removeOrphanedFiles(dir, orphans, `in ${dir} no longer present on WordPress`);
56
- this.log(`Pulled ${remote.length} ${postType} post-meta file(s) to ${dir}`);
38
+ await this.pullDirectory(dir, remote, orphans, {
39
+ dryRunMessage: `Would pull ${remote.length} ${postType} post-meta file(s) to ${dir}`,
40
+ orphanReason: `in ${dir} no longer present on WordPress`,
41
+ pulledMessage: `Pulled ${remote.length} ${postType} post-meta file(s) to ${dir}`,
42
+ title: (post) => post.slug,
43
+ write: async (post, writeDir) => writeFile(join(writeDir, `${post.slug}.json`), JSON.stringify(post, null, 2) + '\n'),
44
+ });
57
45
  }
58
46
  // Redirects are only supported by some SeoProvider backends (RankMath, not Yoast). Unlike
59
47
  // push (which must fail loudly if the user has local redirect files that can't be synced),
@@ -73,24 +61,13 @@ export default class Pull extends LoopressCommand {
73
61
  extensions: ['.json'],
74
62
  key: numericPrefixKey,
75
63
  });
76
- if (this.dryRun) {
77
- this.log(`[dry-run] Would pull ${remote.length} redirect(s) to ${dir}`);
78
- if (orphans.length > 0) {
79
- this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${dir} no longer present on WordPress: ${orphans.join(', ')}`);
80
- }
81
- return;
82
- }
83
- if (remote.length > 0)
84
- await mkdir(dir, { recursive: true });
85
- await new Listr(remote.map((redirect) => ({
86
- async task(_ctx, task) {
87
- await writeFile(join(dir, `${redirectFileBase(redirect)}.json`), JSON.stringify(redirect, null, 2) + '\n');
88
- task.output = `Pulled: redirect #${redirect.id}`;
89
- },
90
- title: `Pull redirect #${redirect.id}`,
91
- }))).run();
92
- await this.removeOrphanedFiles(dir, orphans, `in ${dir} no longer present on WordPress`);
93
- this.log(`Pulled ${remote.length} redirect(s) to ${dir}`);
64
+ await this.pullDirectory(dir, remote, orphans, {
65
+ dryRunMessage: `Would pull ${remote.length} redirect(s) to ${dir}`,
66
+ orphanReason: `in ${dir} no longer present on WordPress`,
67
+ pulledMessage: `Pulled ${remote.length} redirect(s) to ${dir}`,
68
+ title: (redirect) => `redirect #${redirect.id}`,
69
+ write: async (redirect, writeDir) => writeFile(join(writeDir, `${redirectFileBase(redirect)}.json`), JSON.stringify(redirect, null, 2) + '\n'),
70
+ });
94
71
  }
95
72
  async pullSettings(basePath) {
96
73
  const file = join(basePath, 'settings.json');
@@ -1,9 +1,10 @@
1
1
  import { Args } from '@oclif/core';
2
- import { Listr } from 'listr2';
3
- import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { readFile, rm, writeFile } from 'node:fs/promises';
4
3
  import { dirname, extname, join } from 'node:path';
5
4
  import { PushCommand } from '../../lib/push-command.js';
6
- import { isNotFoundError } from '../../lib/wp-client.js';
5
+ import { putOrCreate } from '../../lib/put-or-create.js';
6
+ import { readdirTolerant } from '../../lib/readdir-tolerant.js';
7
+ import { pluralize } from '../../utils/pluralize.js';
7
8
  import { redirectFileBase, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, seoRedirectEndpoint, } from '../../utils/seo-format.js';
8
9
  export default class Push extends PushCommand {
9
10
  static args = {
@@ -25,7 +26,7 @@ export default class Push extends PushCommand {
25
26
  await this.pushPostMeta(path);
26
27
  await this.pushRedirects(path);
27
28
  if (this.failedCount > 0) {
28
- this.error(`${this.failedCount} SEO item${this.failedCount === 1 ? '' : 's'} failed to push.`);
29
+ this.error(`${pluralize(this.failedCount, 'SEO item')} failed to push.`);
29
30
  }
30
31
  if (this.dryRun)
31
32
  return;
@@ -33,36 +34,20 @@ export default class Push extends PushCommand {
33
34
  this.log('All SEO configuration pushed.');
34
35
  }
35
36
  async jsonFilesIn(dir) {
36
- try {
37
- return (await readdir(dir)).filter((file) => extname(file) === '.json');
38
- }
39
- catch (error) {
40
- if (error.code === 'ENOENT')
41
- return [];
42
- throw error;
43
- }
37
+ return (await readdirTolerant(dir)).filter((file) => extname(file) === '.json');
44
38
  }
45
39
  async pushPostMeta(basePath) {
46
40
  const root = join(basePath, 'post-meta');
47
- let postTypeDirs;
48
- try {
49
- postTypeDirs = (await readdir(root, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
50
- }
51
- catch (error) {
52
- if (error.code === 'ENOENT')
53
- return;
54
- throw error;
55
- }
41
+ const postTypeDirs = (await readdirTolerant(root, { withFileTypes: true }))
42
+ .filter((entry) => entry.isDirectory())
43
+ .map((entry) => entry.name);
56
44
  for (const postType of postTypeDirs) {
57
45
  const dir = join(root, postType);
58
46
  const files = await this.jsonFilesIn(dir);
59
47
  if (files.length === 0)
60
48
  continue;
61
- this.log(`Found ${files.length} ${postType} post-meta file${files.length === 1 ? '' : 's'} to push`);
62
- await new Listr(files.map((file) => ({
63
- task: async (_ctx, task) => this.pushPostMetaFile(postType, join(dir, file), task),
64
- title: `Push ${file}`,
65
- })), { concurrent: false, exitOnError: false }).run();
49
+ this.log(`Found ${pluralize(files.length, postType + ' post-meta file')} to push`);
50
+ await this.runPushTasks(files, (file) => file, async (file, task) => this.pushPostMetaFile(postType, join(dir, file), task));
66
51
  }
67
52
  }
68
53
  async pushPostMetaFile(postType, filePath, task) {
@@ -90,24 +75,18 @@ export default class Push extends PushCommand {
90
75
  try {
91
76
  const redirect = JSON.parse(await readFile(filePath, 'utf8'));
92
77
  const payload = { headerCode: redirect.headerCode, sources: redirect.sources, status: redirect.status, urlTo: redirect.urlTo };
93
- if (redirect.id) {
94
- try {
95
- await this.wp.put(seoRedirectEndpoint(redirect.id), payload);
96
- if (task)
97
- task.output = `Pushed: redirect #${redirect.id}`;
98
- return;
99
- }
100
- catch (error) {
101
- // The id recorded locally doesn't exist on this site (e.g. a fresh install): create it
102
- // instead of failing, and adopt whatever id the site assigns (same fallback as snippet push).
103
- if (!isNotFoundError(error))
104
- throw error;
105
- }
106
- }
107
- const created = await this.wp.post(SEO_REDIRECTS_ENDPOINT, payload);
108
- await this.renameToCanonical(filePath, created);
78
+ // The id recorded locally may not exist on this site (e.g. a fresh install): putOrCreate
79
+ // falls back to POST instead of failing, adopting whatever id the site assigns.
80
+ const { body, created } = await putOrCreate(this.wp, {
81
+ id: redirect.id ?? null,
82
+ payload,
83
+ postEndpoint: SEO_REDIRECTS_ENDPOINT,
84
+ putEndpoint: (id) => seoRedirectEndpoint(id),
85
+ });
86
+ if (created)
87
+ await this.renameToCanonical(filePath, body);
109
88
  if (task)
110
- task.output = `Pushed: redirect #${created.id}`;
89
+ task.output = `Pushed: redirect #${created ? body.id : redirect.id}`;
111
90
  }
112
91
  catch (error) {
113
92
  this.reportTaskFailure(`Failed to push ${filePath}: ${error.message}`, error, task);
@@ -118,11 +97,8 @@ export default class Push extends PushCommand {
118
97
  const files = await this.jsonFilesIn(dir);
119
98
  if (files.length === 0)
120
99
  return;
121
- this.log(`Found ${files.length} redirect${files.length === 1 ? '' : 's'} to push`);
122
- await new Listr(files.map((file) => ({
123
- task: async (_ctx, task) => this.pushRedirectFile(join(dir, file), task),
124
- title: `Push ${file}`,
125
- })), { concurrent: false, exitOnError: false }).run();
100
+ this.log(`Found ${pluralize(files.length, 'redirect')} to push`);
101
+ await this.runPushTasks(files, (file) => file, async (file, task) => this.pushRedirectFile(join(dir, file), task));
126
102
  }
127
103
  async pushSettings(basePath) {
128
104
  const file = join(basePath, 'settings.json');
@@ -1,4 +1,5 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ import { pluralize } from '../../utils/pluralize.js';
2
3
  import { normalizeSnippet, SNIPPETS_ENDPOINT } from '../../utils/snippet-format.js';
3
4
  export default class List extends LoopressCommand {
4
5
  static description = 'List snippets from WordPress';
@@ -11,7 +12,7 @@ export default class List extends LoopressCommand {
11
12
  this.log('No snippets found');
12
13
  return snippets;
13
14
  }
14
- this.log(`Found ${snippets.length} snippet${snippets.length === 1 ? '' : 's'}:`);
15
+ this.log(`Found ${pluralize(snippets.length, 'snippet')}:`);
15
16
  this.log('');
16
17
  for (const snippet of snippets) {
17
18
  this.log(` ${snippet.id}. ${snippet.name}`);
@@ -1,10 +1,9 @@
1
1
  import { Args, Command } from '@oclif/core';
2
2
  import { join } from 'node:path';
3
- import { authManager } from '../../config/auth.manager.js';
4
- import { configManager } from '../../config/project-config.manager.js';
5
3
  import { ApiClient } from '../../lib/api-client.js';
6
4
  import { loadSnippets } from '../../lib/load-snippets.js';
7
- import { readLocalConfig } from '../../utils/loopress-config.js';
5
+ import { resolveLinkedProject } from '../../lib/resolve-linked-project.js';
6
+ import { pluralize } from '../../utils/pluralize.js';
8
7
  import { toSlug } from '../../utils/to-slug.js';
9
8
  // Publishes to the Loopress api (not a WordPress site), so this does not extend
10
9
  // `LoopressCommand`/`PushCommand`: those force an environment to be resolved, but a project
@@ -18,22 +17,7 @@ export default class Publish extends Command {
18
17
  static examples = ['$ lps snippet publish', '$ lps snippet publish --path ./snippets'];
19
18
  async run() {
20
19
  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 push\` first.`);
36
- }
20
+ const { apiProjectId, localConfig, token } = await resolveLinkedProject((message) => this.error(message));
37
21
  const path = args.path ?? join(localConfig.rootDir ?? '.', localConfig.snippetsDir ?? 'snippets');
38
22
  this.log(`Publishing snippets from ${path}`);
39
23
  let snippets;
@@ -45,14 +29,14 @@ export default class Publish extends Command {
45
29
  }
46
30
  const api = new ApiClient(token);
47
31
  try {
48
- await api.post(`projects/${project.apiProjectId}/snippets/publish`, {
32
+ await api.post(`projects/${apiProjectId}/snippets/publish`, {
49
33
  snippets: snippets.map((snippet) => this.toPayload(snippet)),
50
34
  });
51
35
  }
52
36
  catch (error) {
53
37
  this.error(error.message);
54
38
  }
55
- this.log(`Published ${snippets.length} snippet${snippets.length === 1 ? '' : 's'} to your Loopress account.`);
39
+ this.log(`Published ${pluralize(snippets.length, 'snippet')} to your Loopress account.`);
56
40
  }
57
41
  toPayload(snippet) {
58
42
  return {
@@ -1,9 +1,9 @@
1
1
  import { Args } from '@oclif/core';
2
- import { Listr } from 'listr2';
3
- import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { writeFile } from 'node:fs/promises';
4
3
  import { join } from 'node:path';
5
4
  import { LoopressCommand } from '../../lib/base.js';
6
5
  import { findOrphanedFiles, numericPrefixKey } from '../../lib/find-orphaned-files.js';
6
+ import { pluralize } from '../../utils/pluralize.js';
7
7
  import { buildMetaFile, buildSnippetFile, normalizeSnippet, SNIPPETS_ENDPOINT } from '../../utils/snippet-format.js';
8
8
  import { toSlug } from '../../utils/to-slug.js';
9
9
  const EXTENSIONS = {
@@ -42,28 +42,23 @@ export default class Pull extends LoopressCommand {
42
42
  key: numericPrefixKey,
43
43
  });
44
44
  const pulled = pullable.map((snippet) => ({ id: snippet.id, name: snippet.name }));
45
- if (this.dryRun) {
46
- this.log(`[dry-run] Would pull ${snippets.length} snippet${snippets.length === 1 ? '' : 's'} to ${path}`);
47
- if (orphans.length > 0) {
48
- this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} whose snippet no longer exists on WordPress: ${orphans.join(', ')}`);
49
- }
50
- return { orphans, pulled, skipped, status: 'dry-run' };
51
- }
52
- await mkdir(path, { recursive: true });
53
- await new Listr(pullable.map((snippet) => ({
54
- async task(_ctx, task) {
45
+ await this.pullDirectory(path, pullable, orphans, {
46
+ alwaysCreateDir: true,
47
+ dryRunMessage: `Would pull ${pluralize(snippets.length, 'snippet')} to ${path}`,
48
+ orphanReason: 'whose snippet no longer exists on WordPress',
49
+ pulledMessage: `Pulled ${pluralize(pullable.length, 'snippet')} to ${path}`,
50
+ title: (snippet) => snippet.name,
51
+ async write(snippet, writeDir) {
55
52
  const ext = EXTENSIONS[snippet.type];
56
53
  const base = `${snippet.id}-${toSlug(snippet.name)}`;
57
- await writeFile(join(path, `${base}.${ext}`), buildSnippetFile(snippet));
58
- await writeFile(join(path, `${base}.json`), buildMetaFile(snippet));
59
- task.output = `Pulled: ${snippet.name}`;
54
+ await writeFile(join(writeDir, `${base}.${ext}`), buildSnippetFile(snippet));
55
+ await writeFile(join(writeDir, `${base}.json`), buildMetaFile(snippet));
60
56
  },
61
- title: `Pull ${snippet.name}`,
62
- })), { renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
63
- await this.removeOrphanedFiles(path, orphans, 'whose snippet no longer exists on WordPress');
64
- this.log(`Pulled ${pullable.length} snippet${pullable.length === 1 ? '' : 's'} to ${path}`);
57
+ });
58
+ if (this.dryRun)
59
+ return { orphans, pulled, skipped, status: 'dry-run' };
65
60
  if (skipped > 0) {
66
- this.warn(`${skipped} snippet${skipped === 1 ? '' : 's'} skipped because they have no name`);
61
+ this.warn(`${pluralize(skipped, 'snippet')} skipped because they have no name`);
67
62
  }
68
63
  return { orphans, pulled, skipped, status: 'success' };
69
64
  }
@@ -1,10 +1,10 @@
1
1
  import { Args } from '@oclif/core';
2
- import { Listr } from 'listr2';
3
2
  import { readFile, rename, rm, writeFile } from 'node:fs/promises';
4
3
  import { basename, dirname, extname, join } from 'node:path';
5
4
  import { loadSnippets as loadSnippetsFromDisk } from '../../lib/load-snippets.js';
6
5
  import { PushCommand } from '../../lib/push-command.js';
7
- import { isNotFoundError } from '../../lib/wp-client.js';
6
+ import { putOrCreate } from '../../lib/put-or-create.js';
7
+ import { pluralize } from '../../utils/pluralize.js';
8
8
  import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../../utils/snippet-format.js';
9
9
  import { toSlug } from '../../utils/to-slug.js';
10
10
  export default class Push extends PushCommand {
@@ -25,17 +25,14 @@ export default class Push extends PushCommand {
25
25
  this.log(`Pushing snippets to ${url}`);
26
26
  this.log(`Snippets path: ${path}`);
27
27
  const snippets = await this.loadSnippets(path);
28
- this.log(`Found ${snippets.length} snippet${snippets.length === 1 ? '' : 's'} to push`);
28
+ this.log(`Found ${pluralize(snippets.length, 'snippet')} to push`);
29
29
  const pushed = [];
30
- await new Listr(snippets.map((snippet) => ({
31
- task: async (_ctx, task) => {
32
- const id = await this.pushSnippet(snippet, task);
33
- pushed.push({ id, name: snippet.name });
34
- },
35
- title: `Push ${snippet.name}`,
36
- })), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
30
+ await this.runPushTasks(snippets, (snippet) => snippet.name, async (snippet, task) => {
31
+ const id = await this.pushSnippet(snippet, task);
32
+ pushed.push({ id, name: snippet.name });
33
+ });
37
34
  if (this.failedCount > 0) {
38
- this.error(`${this.failedCount} snippet${this.failedCount === 1 ? '' : 's'} failed to push.`);
35
+ this.error(`${pluralize(this.failedCount, 'snippet')} failed to push.`);
39
36
  }
40
37
  if (this.dryRun)
41
38
  return { pushed, status: 'dry-run' };
@@ -93,28 +90,22 @@ export default class Push extends PushCommand {
93
90
  }
94
91
  try {
95
92
  const payload = this.toPayload(snippet);
93
+ // The id recorded locally may not exist on this site (e.g. a fresh install): putOrCreate
94
+ // falls back to POST instead of failing, adopting whatever id the site assigns.
95
+ const { body, created } = await putOrCreate(this.wp, {
96
+ id: snippet.id ?? null,
97
+ payload,
98
+ postEndpoint: SNIPPETS_ENDPOINT,
99
+ putEndpoint: (id) => `${SNIPPETS_ENDPOINT}/${id}`,
100
+ });
96
101
  let { id } = snippet;
97
- if (snippet.id) {
98
- try {
99
- await this.wp.put(`${SNIPPETS_ENDPOINT}/${snippet.id}`, payload);
100
- await this.ensureCanonicalFilename(snippet, snippet.id, snippet.name);
101
- }
102
- catch (error) {
103
- // The id recorded locally doesn't exist on this site (e.g. a fresh install): create it
104
- // instead of failing, and adopt whatever id the site assigns.
105
- if (!isNotFoundError(error))
106
- throw error;
107
- const response = await this.wp.post(SNIPPETS_ENDPOINT, payload);
108
- const created = normalizeSnippet(response);
109
- id = created.id;
110
- await this.ensureCanonicalFilename(snippet, created.id, created.name);
111
- }
102
+ if (created) {
103
+ const createdSnippet = normalizeSnippet(body);
104
+ id = createdSnippet.id;
105
+ await this.ensureCanonicalFilename(snippet, createdSnippet.id, createdSnippet.name);
112
106
  }
113
107
  else {
114
- const response = await this.wp.post(SNIPPETS_ENDPOINT, payload);
115
- const created = normalizeSnippet(response);
116
- id = created.id;
117
- await this.ensureCanonicalFilename(snippet, created.id, created.name);
108
+ await this.ensureCanonicalFilename(snippet, snippet.id, snippet.name);
118
109
  }
119
110
  if (task)
120
111
  task.output = `Pushed: ${snippet.name}`;
@@ -0,0 +1,13 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ export default class Add extends LoopressCommand {
3
+ static args: {
4
+ slug: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ version: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ };
12
+ run(): Promise<void>;
13
+ }
@@ -0,0 +1,35 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { LoopressCommand } from '../../lib/base.js';
3
+ import { writeLocalConfig } from '../../utils/loopress-config.js';
4
+ import { isExactVersion } from '../../utils/version.js';
5
+ export default class Add extends LoopressCommand {
6
+ static args = {
7
+ slug: Args.string({ description: 'Theme slug on WordPress.org', required: true }),
8
+ };
9
+ static description = 'Add a WordPress.org theme to loopress.json';
10
+ static examples = ['$ lps theme add generatepress', '$ lps theme add generatepress --version 3.4.0'];
11
+ static flags = {
12
+ ...LoopressCommand.dryRunFlag,
13
+ version: Flags.string({ description: 'Exact version to pin (default: "latest")' }),
14
+ };
15
+ async run() {
16
+ const { args, flags } = await this.parse(Add);
17
+ const { slug } = args;
18
+ const version = flags.version ?? 'latest';
19
+ if (version !== 'latest' && !isExactVersion(version)) {
20
+ this.error(`--version must be an exact version like 3.4.0, not a Composer constraint. Got "${version}".`);
21
+ }
22
+ const existing = this.localConfig.themes ?? {};
23
+ if (existing[slug] === version) {
24
+ this.log(`${slug} is already pinned to ${version}, nothing to do.`);
25
+ return;
26
+ }
27
+ const isUpdated = existing[slug] !== undefined;
28
+ if (this.dryRun) {
29
+ this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version})`);
30
+ return;
31
+ }
32
+ await writeLocalConfig({ ...this.localConfig, themes: { ...existing, [slug]: version } });
33
+ this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version}). Run \`lps theme push\` to apply.`);
34
+ }
35
+ }
@@ -0,0 +1,21 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ type PullResult = {
3
+ added: string[];
4
+ merged: Record<string, string>;
5
+ status: 'composer-managed' | 'dry-run' | 'success';
6
+ updated: Array<{
7
+ from: string;
8
+ slug: string;
9
+ to: string;
10
+ }>;
11
+ };
12
+ export default class Pull extends LoopressCommand {
13
+ static description: string;
14
+ static enableJsonFlag: boolean;
15
+ static examples: string[];
16
+ static flags: {
17
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ };
19
+ run(): Promise<PullResult>;
20
+ }
21
+ export {};
@@ -0,0 +1,37 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { LoopressCommand } from '../../lib/base.js';
4
+ import { writeLocalConfig } from '../../utils/loopress-config.js';
5
+ import { mergePluginManifest } from '../../utils/plugins.js';
6
+ import { parseInstalledThemes } from '../../utils/themes.js';
7
+ export default class Pull extends LoopressCommand {
8
+ static description = 'Pull installed themes from WordPress into loopress.json, pinned to their live versions';
9
+ static enableJsonFlag = true;
10
+ static examples = ['$ lps theme pull'];
11
+ static flags = {
12
+ ...LoopressCommand.dryRunFlag,
13
+ };
14
+ async run() {
15
+ const { url } = this.siteConfig;
16
+ if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
17
+ this.warn('This project has a composer.json, which is authoritative. Run `lps composer pull` instead.');
18
+ return { added: [], merged: this.localConfig.themes ?? {}, status: 'composer-managed', updated: [] };
19
+ }
20
+ this.log(`Pulling themes from ${url}`);
21
+ const raw = await this.wp.get('wp/v2/themes');
22
+ const installed = parseInstalledThemes(raw);
23
+ const incoming = Object.fromEntries(installed.map((t) => [t.slug, t.version]));
24
+ const { added, merged, updated } = mergePluginManifest(this.localConfig.themes ?? {}, incoming);
25
+ if (this.dryRun) {
26
+ this.log(`[dry-run] Would write ${Object.keys(merged).length} themes to loopress.json`);
27
+ return { added, merged, status: 'dry-run', updated };
28
+ }
29
+ await writeLocalConfig({ ...this.localConfig, themes: merged });
30
+ this.log(`Wrote ${Object.keys(merged).length} themes to loopress.json`);
31
+ if (added.length > 0)
32
+ this.log(` + Added: ${added.join(', ')}`);
33
+ for (const u of updated)
34
+ this.log(` ~ Updated: ${u.slug} ${u.from} → ${u.to}`);
35
+ return { added, merged, status: 'success', updated };
36
+ }
37
+ }
@@ -0,0 +1,23 @@
1
+ import { PushCommand } from '../../lib/push-command.js';
2
+ type PushResult = {
3
+ installed: string[];
4
+ pinned: string[];
5
+ removed: string[];
6
+ status: 'composer-managed' | 'dry-run' | 'in-sync' | 'success';
7
+ };
8
+ export default class Push extends PushCommand {
9
+ static description: string;
10
+ static enableJsonFlag: boolean;
11
+ static examples: string[];
12
+ static flags: {
13
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ };
17
+ run(): Promise<PushResult>;
18
+ private confirmRemovals;
19
+ private fetchInstanceLock;
20
+ private guardForce;
21
+ private sync;
22
+ }
23
+ export {};