@loopress/cli 0.21.0 → 0.22.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 (65) hide show
  1. package/README.md +169 -56
  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/dev.js +1 -1
  11. package/dist/commands/doctor.js +2 -1
  12. package/dist/commands/form/list.d.ts +2 -4
  13. package/dist/commands/form/list.js +3 -10
  14. package/dist/commands/form/pull.js +15 -24
  15. package/dist/commands/form/push.js +17 -42
  16. package/dist/commands/login.js +6 -2
  17. package/dist/commands/logout.js +2 -1
  18. package/dist/commands/page/pull.js +17 -28
  19. package/dist/commands/page/push.js +27 -52
  20. package/dist/commands/plugin/pull.d.ts +1 -0
  21. package/dist/commands/plugin/pull.js +8 -10
  22. package/dist/commands/plugin/push.d.ts +3 -0
  23. package/dist/commands/plugin/push.js +32 -22
  24. package/dist/commands/project/config.js +4 -1
  25. package/dist/commands/project/list.d.ts +16 -1
  26. package/dist/commands/project/list.js +22 -16
  27. package/dist/commands/project/pull.js +3 -2
  28. package/dist/commands/project/push.d.ts +5 -0
  29. package/dist/commands/project/push.js +79 -58
  30. package/dist/commands/promote.d.ts +18 -0
  31. package/dist/commands/promote.js +90 -0
  32. package/dist/commands/pull.d.ts +12 -0
  33. package/dist/commands/pull.js +51 -0
  34. package/dist/commands/push.d.ts +13 -0
  35. package/dist/commands/push.js +57 -0
  36. package/dist/commands/seo/list.d.ts +9 -2
  37. package/dist/commands/seo/list.js +9 -11
  38. package/dist/commands/seo/pull.js +14 -37
  39. package/dist/commands/seo/push.js +24 -48
  40. package/dist/commands/snippet/list.js +2 -1
  41. package/dist/commands/snippet/publish.js +5 -21
  42. package/dist/commands/snippet/pull.js +15 -20
  43. package/dist/commands/snippet/push.js +21 -30
  44. package/dist/commands/validate.d.ts +8 -0
  45. package/dist/commands/validate.js +24 -0
  46. package/dist/lib/base.d.ts +9 -0
  47. package/dist/lib/base.js +51 -22
  48. package/dist/lib/guard-production-push.d.ts +12 -0
  49. package/dist/lib/guard-production-push.js +20 -0
  50. package/dist/lib/load-snippets.js +65 -60
  51. package/dist/lib/local-callback-server.js +9 -4
  52. package/dist/lib/push-command.d.ts +3 -0
  53. package/dist/lib/push-command.js +22 -15
  54. package/dist/lib/put-or-create.d.ts +13 -0
  55. package/dist/lib/put-or-create.js +19 -0
  56. package/dist/lib/readdir-tolerant.d.ts +5 -0
  57. package/dist/lib/readdir-tolerant.js +11 -0
  58. package/dist/lib/resolve-linked-project.d.ts +8 -0
  59. package/dist/lib/resolve-linked-project.js +27 -0
  60. package/dist/lib/validate-local.d.ts +10 -0
  61. package/dist/lib/validate-local.js +183 -0
  62. package/dist/utils/pluralize.d.ts +1 -0
  63. package/dist/utils/pluralize.js +5 -0
  64. package/oclif.manifest.json +242 -27
  65. package/package.json +16 -15
@@ -0,0 +1,57 @@
1
+ import { LoopressCommand } from '../lib/base.js';
2
+ import { guardProductionPush } from '../lib/guard-production-push.js';
3
+ import { pluralize } from '../utils/pluralize.js';
4
+ // Dependency-ish order: code first (plugins, composer), then the ACF definitions content
5
+ // relies on, then content itself.
6
+ const PUSH_TARGETS = [
7
+ { commandId: 'plugin:push', label: 'plugins' },
8
+ { commandId: 'composer:push', label: 'composer' },
9
+ { commandId: 'acf:push', label: 'ACF' },
10
+ { commandId: 'api:push', label: 'API routes' },
11
+ { commandId: 'form:push', label: 'forms' },
12
+ { commandId: 'page:push', label: 'pages' },
13
+ { commandId: 'seo:push', label: 'SEO' },
14
+ { commandId: 'snippet:push', label: 'snippets' },
15
+ ];
16
+ export default class Push extends LoopressCommand {
17
+ static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, forms, pages, SEO, and snippets, to WordPress';
18
+ static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
19
+ static flags = {
20
+ ...LoopressCommand.dryRunFlag,
21
+ ...LoopressCommand.yesFlag,
22
+ };
23
+ failedCount = 0;
24
+ async run() {
25
+ await this.guardProductionPush();
26
+ const argv = this.buildArgv();
27
+ for (const target of PUSH_TARGETS) {
28
+ this.log(`\n→ Pushing ${target.label}...`);
29
+ try {
30
+ await this.config.runCommand(target.commandId, argv);
31
+ this.log(`✓ ${target.label} pushed`);
32
+ }
33
+ catch (error) {
34
+ this.failedCount++;
35
+ this.log(`✗ ${target.label} failed: ${error.message}`);
36
+ }
37
+ }
38
+ if (this.failedCount > 0) {
39
+ this.error(`${pluralize(this.failedCount, 'resource')} failed to push.`);
40
+ }
41
+ this.log('\nAll resources pushed.');
42
+ }
43
+ // Every delegated push always gets --yes: either this command's own guard already confirmed
44
+ // production once above, or the target isn't production and there's nothing to confirm.
45
+ // Without this, each of the 8 delegated commands would re-run its own production guard.
46
+ buildArgv() {
47
+ const argv = ['--env', this.siteConfig.name, '--yes'];
48
+ if (this.dryRun)
49
+ argv.push('--dry-run');
50
+ return argv;
51
+ }
52
+ // Guards once here (shared with PushCommand, lib/push-command.ts) rather than once per
53
+ // delegated command.
54
+ async guardProductionPush() {
55
+ await guardProductionPush({ dryRun: this.dryRun, error: (message) => this.error(message), siteConfig: this.siteConfig, yes: this.yes });
56
+ }
57
+ }
@@ -1,11 +1,18 @@
1
1
  import { LoopressCommand } from '../../lib/base.js';
2
+ import { type SeoPostMeta, type SeoRedirect } from '../../utils/seo-format.js';
3
+ type ListResult = {
4
+ postMeta: Record<string, SeoPostMeta[]>;
5
+ redirects: SeoRedirect[] | undefined;
6
+ redirectsUnsupported: string | undefined;
7
+ };
2
8
  export default class List extends LoopressCommand {
3
9
  static description: string;
10
+ static enableJsonFlag: boolean;
4
11
  static examples: string[];
5
12
  static flags: {
6
- json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
13
  'post-type': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
14
  };
9
- run(): Promise<void>;
15
+ run(): Promise<ListResult>;
10
16
  private fetchRedirects;
11
17
  }
18
+ export {};
@@ -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,8 @@
1
+ import { Command } from '@oclif/core';
2
+ import { type ValidateResult } from '../lib/validate-local.js';
3
+ export default class Validate extends Command {
4
+ static description: string;
5
+ static enableJsonFlag: boolean;
6
+ static examples: string[];
7
+ run(): Promise<ValidateResult>;
8
+ }
@@ -0,0 +1,24 @@
1
+ import { Command } from '@oclif/core';
2
+ import { validateLocal } from '../lib/validate-local.js';
3
+ export default class Validate extends Command {
4
+ static description = 'Check local tracked files are well formed and push-ready, without contacting WordPress';
5
+ static enableJsonFlag = true;
6
+ static examples = ['$ lps validate'];
7
+ async run() {
8
+ await this.parse(Validate);
9
+ const result = await validateLocal(process.cwd());
10
+ if (!this.jsonEnabled()) {
11
+ for (const problem of result.problems) {
12
+ this.log(` ✗ ${problem.file}: ${problem.message}`);
13
+ }
14
+ this.log('');
15
+ this.log(result.valid
16
+ ? `${result.checked} file(s) checked, no problems found.`
17
+ : `${result.checked} file(s) checked, ${result.problems.length} problem(s) found.`);
18
+ }
19
+ // Non-zero exit for CI without throwing, so `--json` still prints the full report.
20
+ if (!result.valid)
21
+ process.exitCode = 1;
22
+ return result;
23
+ }
24
+ }
@@ -22,6 +22,14 @@ export declare abstract class LoopressCommand extends Command {
22
22
  protected get wp(): WpClient;
23
23
  init(): Promise<void>;
24
24
  protected maybeAutoRotate(): Promise<void>;
25
+ protected pullDirectory<T>(dir: string, items: T[], orphans: string[], options: {
26
+ alwaysCreateDir?: boolean;
27
+ dryRunMessage: string;
28
+ orphanReason: string;
29
+ pulledMessage: string;
30
+ title: (item: T) => string;
31
+ write: (item: T, dir: string) => Promise<void>;
32
+ }): Promise<void>;
25
33
  protected removeOrphanedFiles(dir: string, orphans: string[], reason: string): Promise<void>;
26
34
  protected resolveAcfPath(override?: string): string;
27
35
  protected resolveApiPath(override?: string): string;
@@ -31,4 +39,5 @@ export declare abstract class LoopressCommand extends Command {
31
39
  protected resolveSnippetsPath(override?: string): string;
32
40
  private pickEnvironment;
33
41
  private resolveEnvironment;
42
+ private resolveEnvironmentForConfiguredProject;
34
43
  }