@loopress/cli 0.17.0 → 0.18.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.
@@ -0,0 +1,79 @@
1
+ import { Args } from '@oclif/core';
2
+ import { Listr } from 'listr2';
3
+ import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
4
+ import { extname, join } from 'node:path';
5
+ import slugify from 'slugify';
6
+ import { LoopressCommand } from '../../lib/base.js';
7
+ import { FORM_ENDPOINT, getFormId, getFormTitle } from '../../utils/form-format.js';
8
+ export default class Pull extends LoopressCommand {
9
+ static args = {
10
+ path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
11
+ };
12
+ static description = 'Pull forms from WordPress';
13
+ static examples = ['$ lps form pull'];
14
+ static flags = {
15
+ ...LoopressCommand.dryRunFlag,
16
+ };
17
+ async run() {
18
+ const { args } = await this.parse(Pull);
19
+ const { url } = this.siteConfig;
20
+ const path = this.resolveFormPath(args.path);
21
+ this.log(`Pulling forms from ${url}`);
22
+ this.log(`Forms path: ${path}`);
23
+ const remoteList = await this.wp.get(FORM_ENDPOINT);
24
+ const withId = remoteList.filter((form) => getFormId(form) !== null);
25
+ const skipped = remoteList.length - withId.length;
26
+ const orphans = await this.findOrphanedFiles(path, new Set(withId.map((form) => getFormId(form))));
27
+ if (this.dryRun) {
28
+ this.log(`[dry-run] Would pull ${withId.length} form${withId.length === 1 ? '' : 's'} to ${path}`);
29
+ if (orphans.length > 0) {
30
+ this.log(`[dry-run] Would remove ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${path} no longer present on WordPress: ${orphans.join(', ')}`);
31
+ }
32
+ return;
33
+ }
34
+ if (withId.length > 0)
35
+ await mkdir(path, { recursive: true });
36
+ await new Listr(withId.map((form) => {
37
+ const id = getFormId(form);
38
+ const title = getFormTitle(form);
39
+ return {
40
+ async task(_ctx, task) {
41
+ await writeFile(join(path, `${id}-${slug(title)}.json`), JSON.stringify(form, null, 2) + '\n');
42
+ task.output = `Pulled: ${title}`;
43
+ },
44
+ title: `Pull ${title}`,
45
+ };
46
+ })).run();
47
+ for (const file of orphans)
48
+ await rm(join(path, file), { force: true });
49
+ if (orphans.length > 0) {
50
+ this.warn(`Removed ${orphans.length} local file${orphans.length === 1 ? '' : 's'} in ${path} no longer present on WordPress: ${orphans.join(', ')}`);
51
+ }
52
+ this.log(`Pulled ${withId.length} form${withId.length === 1 ? '' : 's'} to ${path}`);
53
+ if (skipped > 0) {
54
+ this.warn(`${skipped} form${skipped === 1 ? '' : 's'} skipped because they have no id`);
55
+ }
56
+ }
57
+ // Only matches files following the `<id>-<slug>.json` convention pull/push themselves
58
+ // produce, same principle as findOrphanedFiles in commands/snippet/pull.ts.
59
+ async findOrphanedFiles(dir, keepIds) {
60
+ let files;
61
+ try {
62
+ files = await readdir(dir);
63
+ }
64
+ catch (error) {
65
+ if (error.code === 'ENOENT')
66
+ return [];
67
+ throw error;
68
+ }
69
+ return files.filter((file) => {
70
+ if (extname(file) !== '.json')
71
+ return false;
72
+ const match = /^(\d+)-/.exec(file);
73
+ return match !== null && !keepIds.has(Number(match[1]));
74
+ });
75
+ }
76
+ }
77
+ export function slug(title) {
78
+ return slugify(title, { lower: true, strict: true }) || 'untitled';
79
+ }
@@ -0,0 +1,16 @@
1
+ import { PushCommand } from '../../lib/push-command.js';
2
+ export default class Push extends PushCommand {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ };
11
+ private failedCount;
12
+ run(): Promise<void>;
13
+ private ensureCanonicalFilename;
14
+ private loadFiles;
15
+ private pushForm;
16
+ }
@@ -0,0 +1,130 @@
1
+ import { Args } from '@oclif/core';
2
+ import { Listr } from 'listr2';
3
+ import { readdir, readFile, rename } from 'node:fs/promises';
4
+ import { dirname, extname, join } from 'node:path';
5
+ import { PushCommand } from '../../lib/push-command.js';
6
+ import { isNotFoundError } from '../../lib/wp-client.js';
7
+ import { FORM_ENDPOINT, getFormId, getFormTitle } from '../../utils/form-format.js';
8
+ import { slug } from './pull.js';
9
+ export default class Push extends PushCommand {
10
+ static args = {
11
+ path: Args.string({ description: 'Path to forms directory (overrides project config)' }),
12
+ };
13
+ static description = 'Push forms to WordPress. Local files created or updated remotely are renamed on disk to the `<id>-<slug>.json` convention.';
14
+ static examples = ['$ lps form push'];
15
+ static flags = {
16
+ ...PushCommand.dryRunFlag,
17
+ };
18
+ failedCount = 0;
19
+ async run() {
20
+ const { args } = await this.parse(Push);
21
+ const { url } = this.siteConfig;
22
+ const path = this.resolveFormPath(args.path);
23
+ this.log(`Pushing forms to ${url}`);
24
+ this.log(`Forms path: ${path}`);
25
+ const files = await this.loadFiles(path);
26
+ this.log(`Found ${files.length} form${files.length === 1 ? '' : 's'} to push`);
27
+ await new Listr(files.map(({ data, filePath }) => ({
28
+ task: async (_ctx, task) => this.pushForm(filePath, data, task),
29
+ title: `Push ${getFormTitle(data)}`,
30
+ })), { concurrent: false, exitOnError: false }).run();
31
+ if (this.failedCount > 0) {
32
+ this.error(`${this.failedCount} form${this.failedCount === 1 ? '' : 's'} failed to push.`);
33
+ }
34
+ if (this.dryRun)
35
+ return;
36
+ await this.recordSuccess();
37
+ this.log('All forms pushed.');
38
+ }
39
+ // Renames the local file to the `<id>-<slug>.json` convention used by `form pull`
40
+ // whenever it doesn't already match (a hand-created file with no id, or a stale slug after
41
+ // a title change in the WordPress admin), same principle as ensureCanonicalFilename in
42
+ // commands/snippet/push.ts.
43
+ async ensureCanonicalFilename(filePath, id, title) {
44
+ const canonicalPath = join(dirname(filePath), `${id}-${slug(title)}.json`);
45
+ if (filePath !== canonicalPath)
46
+ await rename(filePath, canonicalPath);
47
+ }
48
+ // One file is read in isolation: a corrupted or hand-broken JSON file must only skip that
49
+ // form, not abort loading the rest of the directory, same principle as loadObjects() in
50
+ // commands/acf/push.ts.
51
+ async loadFiles(dir) {
52
+ let files;
53
+ try {
54
+ files = await readdir(dir);
55
+ }
56
+ catch (error) {
57
+ if (error.code === 'ENOENT')
58
+ return [];
59
+ throw error;
60
+ }
61
+ const forms = [];
62
+ for (const file of files) {
63
+ if (extname(file) !== '.json')
64
+ continue;
65
+ const filePath = join(dir, file);
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(await readFile(filePath, 'utf8'));
69
+ }
70
+ catch (error) {
71
+ this.warn(`Skipping "${filePath}": ${error.message}`);
72
+ continue;
73
+ }
74
+ if (typeof parsed !== 'object' || parsed === null) {
75
+ this.warn(`Skipping "${filePath}": not a JSON object`);
76
+ continue;
77
+ }
78
+ forms.push({ data: parsed, filePath });
79
+ }
80
+ return forms;
81
+ }
82
+ // Throwing on failure (rather than returning a boolean) is what lets Listr mark the task as
83
+ // failed (red cross) instead of completed; `exitOnError: false` on the task list still lets
84
+ // sibling forms push regardless. PUT-then-404-fallback-POST is the same dance as
85
+ // commands/snippet/push.ts, forms are id-based like snippets, not key-based like ACF.
86
+ async pushForm(filePath, data, task) {
87
+ const title = getFormTitle(data);
88
+ if (this.dryRun) {
89
+ if (task)
90
+ task.output = `[dry-run] Would push: ${title}`;
91
+ return;
92
+ }
93
+ try {
94
+ const id = getFormId(data);
95
+ if (id === null) {
96
+ const created = await this.wp.post(FORM_ENDPOINT, data);
97
+ const newId = getFormId(created);
98
+ if (newId !== null)
99
+ await this.ensureCanonicalFilename(filePath, newId, title);
100
+ }
101
+ else {
102
+ try {
103
+ await this.wp.put(`${FORM_ENDPOINT}/${id}`, data);
104
+ await this.ensureCanonicalFilename(filePath, id, title);
105
+ }
106
+ catch (error) {
107
+ // The id recorded locally doesn't exist on this site (e.g. a fresh install): create
108
+ // it instead of failing, and adopt whatever id the site assigns.
109
+ if (!isNotFoundError(error))
110
+ throw error;
111
+ const created = await this.wp.post(FORM_ENDPOINT, data);
112
+ const newId = getFormId(created);
113
+ if (newId !== null)
114
+ await this.ensureCanonicalFilename(filePath, newId, title);
115
+ }
116
+ }
117
+ if (task)
118
+ task.output = `Pushed: ${title}`;
119
+ }
120
+ catch (error) {
121
+ const message = `Failed to push ${title}: ${error.message}`;
122
+ if (task)
123
+ task.output = message;
124
+ else
125
+ this.warn(` ${message}`);
126
+ this.failedCount++;
127
+ throw error;
128
+ }
129
+ }
130
+ }
@@ -14,6 +14,8 @@ export declare abstract class LoopressCommand extends Command {
14
14
  protected get wp(): WpClient;
15
15
  init(): Promise<void>;
16
16
  protected resolveAcfPath(override?: string): string;
17
+ protected resolveApiPath(override?: string): string;
18
+ protected resolveFormPath(override?: string): string;
17
19
  protected resolveSeoPath(override?: string): string;
18
20
  protected resolveSnippetsPath(override?: string): string;
19
21
  private resolveEnvironment;
package/dist/lib/base.js CHANGED
@@ -40,6 +40,16 @@ export class LoopressCommand extends Command {
40
40
  return override;
41
41
  return join(this.rootDir, this.localConfig.acfDir ?? 'acf');
42
42
  }
43
+ resolveApiPath(override) {
44
+ if (override)
45
+ return override;
46
+ return join(this.rootDir, this.localConfig.apiDir ?? 'api');
47
+ }
48
+ resolveFormPath(override) {
49
+ if (override)
50
+ return override;
51
+ return join(this.rootDir, this.localConfig.formDir ?? 'forms');
52
+ }
43
53
  resolveSeoPath(override) {
44
54
  if (override)
45
55
  return override;
@@ -19,6 +19,14 @@ export interface LoopressProjectConfiguration {
19
19
  * Directory where SEO settings, post meta, and redirects (RankMath and Yoast SEO, whichever is active) are read from and written to, relative to rootDir.
20
20
  */
21
21
  seoDir?: string;
22
+ /**
23
+ * Directory where forms (WPForms today) are read from and written to, relative to rootDir.
24
+ */
25
+ formDir?: string;
26
+ /**
27
+ * Directory where custom API route files are read from and written to, relative to rootDir.
28
+ */
29
+ apiDir?: string;
22
30
  /**
23
31
  * Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
24
32
  */
@@ -0,0 +1,3 @@
1
+ export declare const FORM_ENDPOINT = "loopress/v1/forms";
2
+ export declare function getFormId(data: Record<string, unknown>): null | number;
3
+ export declare function getFormTitle(data: Record<string, unknown>): string;
@@ -0,0 +1,17 @@
1
+ export const FORM_ENDPOINT = 'loopress/v1/forms';
2
+ // Deliberately loose, same reasoning as getAcfKey in acf-format.ts: a form plugin's own data
3
+ // format (WPForms today: fields, settings, notifications, confirmations, providers, meta; other
4
+ // WordPress form plugins may be supported later, same shape as snippet-format.ts's Code
5
+ // Snippets/WPCode split) is large, deeply nested, and versioned by whichever plugin release
6
+ // wrote it. We only need `id` for filenames/identity and `settings.form_title` for display
7
+ // (the field WPForms itself uses; a future provider may differ), everything else round-trips
8
+ // through pull/push untouched.
9
+ export function getFormId(data) {
10
+ const id = Number(data.id);
11
+ return Number.isInteger(id) && id > 0 ? id : null;
12
+ }
13
+ export function getFormTitle(data) {
14
+ const settings = data.settings;
15
+ const title = settings?.form_title;
16
+ return typeof title === 'string' && title.trim() !== '' ? title : '(untitled)';
17
+ }