@loopress/cli 0.19.0 → 0.20.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 (38) hide show
  1. package/README.md +158 -37
  2. package/dist/commands/api/publish.d.ts +9 -0
  3. package/dist/commands/api/publish.js +60 -0
  4. package/dist/commands/api/push.d.ts +1 -0
  5. package/dist/commands/api/push.js +19 -0
  6. package/dist/commands/init.js +1 -0
  7. package/dist/commands/page/list.d.ts +9 -0
  8. package/dist/commands/page/list.js +26 -0
  9. package/dist/commands/page/pull.d.ts +13 -0
  10. package/dist/commands/page/pull.js +65 -0
  11. package/dist/commands/page/push.d.ts +16 -0
  12. package/dist/commands/page/push.js +145 -0
  13. package/dist/commands/project/config.d.ts +1 -0
  14. package/dist/commands/project/config.js +28 -0
  15. package/dist/commands/project/rotate.d.ts +7 -0
  16. package/dist/commands/project/rotate.js +20 -0
  17. package/dist/lib/base.d.ts +3 -0
  18. package/dist/lib/base.js +42 -10
  19. package/dist/lib/bootstrap-full-install.d.ts +10 -0
  20. package/dist/lib/bootstrap-full-install.js +66 -0
  21. package/dist/lib/browser-launch.d.ts +8 -0
  22. package/dist/lib/browser-launch.js +20 -0
  23. package/dist/lib/github-release.d.ts +11 -0
  24. package/dist/lib/github-release.js +37 -0
  25. package/dist/lib/plugin-detection.d.ts +8 -0
  26. package/dist/lib/plugin-detection.js +11 -0
  27. package/dist/lib/rotate-app-password.d.ts +6 -0
  28. package/dist/lib/rotate-app-password.js +33 -0
  29. package/dist/lib/temp-admin.d.ts +19 -0
  30. package/dist/lib/temp-admin.js +46 -0
  31. package/dist/lib/wp-client.d.ts +1 -0
  32. package/dist/lib/wp-client.js +3 -0
  33. package/dist/types/project-config.generated.d.ts +4 -0
  34. package/dist/utils/page-format.d.ts +7 -0
  35. package/dist/utils/page-format.js +78 -0
  36. package/oclif.manifest.json +252 -1
  37. package/package.json +8 -7
  38. package/schemas/project-config.schema.json +5 -0
@@ -0,0 +1,33 @@
1
+ import { WpClient } from './wp-client.js';
2
+ const APPLICATION_PASSWORDS_PATH = 'wp/v2/users/me/application-passwords';
3
+ export const APP_PASSWORD_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
4
+ export function isAppPasswordStale(addedAt) {
5
+ return Date.now() - new Date(addedAt).getTime() > APP_PASSWORD_MAX_AGE_MS;
6
+ }
7
+ // Create the new credential, confirm it actually authenticates on its own, only then revoke
8
+ // the old one. Reversing this order risks locking the user out if the new one is somehow bad.
9
+ export async function rotateAppPassword(env) {
10
+ const [user] = env.token.split(':');
11
+ const oldClient = new WpClient(env.url, env.token);
12
+ const old = await oldClient.get(`${APPLICATION_PASSWORDS_PATH}/introspect`);
13
+ const created = await oldClient.post(APPLICATION_PASSWORDS_PATH, { name: 'Loopress' });
14
+ if (!created.password) {
15
+ throw new Error(`WordPress did not return a password when creating the new application password on ${env.url}.`);
16
+ }
17
+ const newToken = `${user}:${created.password}`;
18
+ const newClient = new WpClient(env.url, newToken);
19
+ try {
20
+ await newClient.get(`${APPLICATION_PASSWORDS_PATH}/introspect`);
21
+ }
22
+ catch (error) {
23
+ // The new credential exists on WordPress but was never confirmed to work: clean it up with
24
+ // the still-valid old credentials instead of leaving an orphan behind. maybeAutoRotate
25
+ // retries on every subsequent run, so a persistent failure here would otherwise pile up
26
+ // one abandoned 'Loopress' entry per attempt. Best-effort: a failure here shouldn't hide
27
+ // the original, more informative error.
28
+ await oldClient.delete(`${APPLICATION_PASSWORDS_PATH}/${created.uuid}`).catch(() => { });
29
+ throw error;
30
+ }
31
+ await newClient.delete(`${APPLICATION_PASSWORDS_PATH}/${old.uuid}`);
32
+ return { ...env, addedAt: new Date().toISOString(), token: newToken };
33
+ }
@@ -0,0 +1,19 @@
1
+ import { WpClient } from './wp-client.js';
2
+ export interface TempAdmin {
3
+ id: number;
4
+ password: string;
5
+ username: string;
6
+ }
7
+ /**
8
+ * Creates a temporary WordPress administrator, the only way to obtain `install_plugins` when
9
+ * all the CLI has is an application password. `.invalid` is the RFC 2606 reserved TLD for
10
+ * addresses guaranteed not to be real, appropriate for an account that has no real mailbox.
11
+ */
12
+ export declare function createTempAdmin(wp: WpClient): Promise<TempAdmin>;
13
+ /**
14
+ * Deletes the temporary admin, reassigning any content it might own to the real user behind
15
+ * the app password. Failure here throws rather than warns, naming the leftover account: a
16
+ * cleanup that silently fails would leave a live administrator with a random password on the
17
+ * target site, discoverable only by someone reading logs closely.
18
+ */
19
+ export declare function deleteTempAdmin(wp: WpClient, admin: TempAdmin): Promise<void>;
@@ -0,0 +1,46 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { isNotFoundError } from './wp-client.js';
3
+ // Only role that carries `install_plugins`/`activate_plugins`; there's nothing narrower to grant.
4
+ const TEMP_ADMIN_ROLE = 'administrator';
5
+ /**
6
+ * Creates a temporary WordPress administrator, the only way to obtain `install_plugins` when
7
+ * all the CLI has is an application password. `.invalid` is the RFC 2606 reserved TLD for
8
+ * addresses guaranteed not to be real, appropriate for an account that has no real mailbox.
9
+ */
10
+ export async function createTempAdmin(wp) {
11
+ const username = `lps-temp-${Date.now().toString(36)}`;
12
+ const password = randomBytes(24).toString('base64url');
13
+ const user = await wp.post('wp/v2/users', {
14
+ email: `${username}@lps-temp.invalid`,
15
+ password,
16
+ roles: [TEMP_ADMIN_ROLE],
17
+ username,
18
+ });
19
+ return { id: user.id, password, username };
20
+ }
21
+ /**
22
+ * Deletes the temporary admin, reassigning any content it might own to the real user behind
23
+ * the app password. Failure here throws rather than warns, naming the leftover account: a
24
+ * cleanup that silently fails would leave a live administrator with a random password on the
25
+ * target site, discoverable only by someone reading logs closely.
26
+ */
27
+ export async function deleteTempAdmin(wp, admin) {
28
+ const me = await wp.get('wp/v2/users/me');
29
+ try {
30
+ await wp.delete(`wp/v2/users/${admin.id}?reassign=${me.id}&force=true`);
31
+ }
32
+ catch (error) {
33
+ throw new Error(`Failed to remove the temporary admin account "${admin.username}" (id ${admin.id}) from the site. Remove it manually from wp-admin.`, { cause: error });
34
+ }
35
+ const stillExists = await wp
36
+ .get(`wp/v2/users/${admin.id}`)
37
+ .then(() => true)
38
+ .catch((error) => {
39
+ if (isNotFoundError(error))
40
+ return false;
41
+ throw error;
42
+ });
43
+ if (stillExists) {
44
+ throw new Error(`Temporary admin account "${admin.username}" (id ${admin.id}) still exists after deletion. Remove it manually from wp-admin.`);
45
+ }
46
+ }
@@ -7,6 +7,7 @@ export declare class WpClient {
7
7
  private readonly siteUrl;
8
8
  private readonly client;
9
9
  constructor(siteUrl: string, token: string);
10
+ delete<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
10
11
  get<T>(path: string, options?: RequestOptions): Promise<T>;
11
12
  post<T = unknown>(path: string, json?: Record<string, unknown>, options?: RequestOptions): Promise<T>;
12
13
  put<T = unknown>(path: string, json?: Record<string, unknown>, options?: RequestOptions): Promise<T>;
@@ -15,6 +15,9 @@ export class WpClient {
15
15
  timeout: { request: REQUEST_TIMEOUT_MS },
16
16
  });
17
17
  }
18
+ async delete(path, options) {
19
+ return this.request('delete', path, undefined, options);
20
+ }
18
21
  async get(path, options) {
19
22
  return this.request('get', path, undefined, options);
20
23
  }
@@ -23,6 +23,10 @@ export interface LoopressProjectConfiguration {
23
23
  * Directory where forms (WPForms today) are read from and written to, relative to rootDir.
24
24
  */
25
25
  formDir?: string;
26
+ /**
27
+ * Directory where WordPress pages are read from and written to, relative to rootDir.
28
+ */
29
+ pageDir?: string;
26
30
  /**
27
31
  * Directory where custom API route files are read from and written to, relative to rootDir.
28
32
  */
@@ -0,0 +1,7 @@
1
+ export declare const PAGE_ENDPOINT = "wp/v2/pages";
2
+ export declare const PAGE_LIST_QUERY = "per_page=100";
3
+ export declare function getPageId(data: Record<string, unknown>): null | number;
4
+ export declare function getPageTitle(data: Record<string, unknown>): string;
5
+ export declare function pageFileBase(id: number, title: string): string;
6
+ export declare function pickPageMeta(data: Record<string, unknown>): Record<string, unknown>;
7
+ export declare function getPageContent(data: Record<string, unknown>): string;
@@ -0,0 +1,78 @@
1
+ import { toSlug } from './to-slug.js';
2
+ // WordPress core's own REST endpoint (`wp/v2/pages`), unlike acf/form/seo/snippet there is no
3
+ // Loopress-plugin controller behind this one, same principle as `wp/v2/plugins` in plugin push/pull.
4
+ export const PAGE_ENDPOINT = 'wp/v2/pages';
5
+ // ponytail: fixed cap, not a paged loop; fine for typical page counts, revisit with real
6
+ // pagination (X-WP-TotalPages) if a site ever has more than 100 pages.
7
+ export const PAGE_LIST_QUERY = 'per_page=100';
8
+ export function getPageId(data) {
9
+ const id = Number(data.id);
10
+ return Number.isInteger(id) && id > 0 ? id : null;
11
+ }
12
+ // `title`/`content`/`excerpt` come back as `{raw, rendered}` from `?context=edit` (what `page
13
+ // pull` requests) but round-trip fine as plain strings too for a hand-created file, same
14
+ // looseness as getFormTitle in form-format.ts.
15
+ export function getPageTitle(data) {
16
+ const { title } = data;
17
+ if (typeof title === 'string' && title.trim() !== '')
18
+ return title;
19
+ if (title && typeof title === 'object') {
20
+ const { raw, rendered } = title;
21
+ if (typeof raw === 'string' && raw.trim() !== '')
22
+ return raw;
23
+ if (typeof rendered === 'string' && rendered.trim() !== '')
24
+ return rendered;
25
+ }
26
+ return '(untitled)';
27
+ }
28
+ export function pageFileBase(id, title) {
29
+ return `${id}-${toSlug(title, 'untitled')}`;
30
+ }
31
+ // The fields WordPress core actually accepts on write to `wp/v2/pages` (confirmed via
32
+ // `OPTIONS wp/v2/pages`), plus `id` for local identity. A GET response carries a lot more:
33
+ // `guid`, `link`, `modified`/`modified_gmt`, `permalink_template`, `generated_slug`,
34
+ // `class_list`, `type`, `_links`, all of it readonly/computed by WordPress, most of it
35
+ // domain- or revision-specific. `_links` alone is the bulk of the file and, like
36
+ // `modified`/`modified_gmt`, changes on every edit regardless of whether the page's actual
37
+ // content changed, which would make every `page pull` produce a spurious git diff if kept.
38
+ const PAGE_META_FIELDS = [
39
+ 'id',
40
+ 'date',
41
+ 'date_gmt',
42
+ 'slug',
43
+ 'status',
44
+ 'password',
45
+ 'parent',
46
+ 'title',
47
+ 'author',
48
+ 'excerpt',
49
+ 'featured_media',
50
+ 'comment_status',
51
+ 'ping_status',
52
+ 'menu_order',
53
+ 'meta',
54
+ 'template',
55
+ ];
56
+ export function pickPageMeta(data) {
57
+ const meta = {};
58
+ for (const field of PAGE_META_FIELDS) {
59
+ if (field in data)
60
+ meta[field] = data[field];
61
+ }
62
+ return meta;
63
+ }
64
+ // `content` comes back as `{raw, rendered}` from `?context=edit` (what `page pull` requests);
65
+ // a hand-created file only has the plain string. This is the one field split out into its own
66
+ // `.html` file by pull/push: raw Gutenberg block markup escaped inside a JSON string is neither
67
+ // readable nor safely hand-editable, same reasoning as the code/meta split in snippet-format.ts.
68
+ export function getPageContent(data) {
69
+ const { content } = data;
70
+ if (typeof content === 'string')
71
+ return content;
72
+ if (content && typeof content === 'object') {
73
+ const { raw } = content;
74
+ if (typeof raw === 'string')
75
+ return raw;
76
+ }
77
+ return '';
78
+ }
@@ -439,6 +439,36 @@
439
439
  "list.js"
440
440
  ]
441
441
  },
442
+ "api:publish": {
443
+ "aliases": [],
444
+ "args": {
445
+ "path": {
446
+ "description": "Path to api directory (overrides project config)",
447
+ "name": "path"
448
+ }
449
+ },
450
+ "description": "Publish custom API routes to your Loopress account so they can be deployed to other projects. Does not touch any WordPress site.",
451
+ "examples": [
452
+ "$ lps api publish",
453
+ "$ lps api publish --path ./api"
454
+ ],
455
+ "flags": {},
456
+ "hasDynamicHelp": false,
457
+ "hiddenAliases": [],
458
+ "id": "api:publish",
459
+ "pluginAlias": "@loopress/cli",
460
+ "pluginName": "@loopress/cli",
461
+ "pluginType": "core",
462
+ "strict": true,
463
+ "enableJsonFlag": false,
464
+ "isESM": true,
465
+ "relativePath": [
466
+ "dist",
467
+ "commands",
468
+ "api",
469
+ "publish.js"
470
+ ]
471
+ },
442
472
  "api:pull": {
443
473
  "aliases": [],
444
474
  "args": {
@@ -888,6 +918,178 @@
888
918
  "push.js"
889
919
  ]
890
920
  },
921
+ "page:list": {
922
+ "aliases": [],
923
+ "args": {},
924
+ "description": "List pages from WordPress",
925
+ "examples": [
926
+ "$ lps page list"
927
+ ],
928
+ "flags": {
929
+ "env": {
930
+ "description": "Target environment by name, overriding the globally active one (lps project switch)",
931
+ "name": "env",
932
+ "hasDynamicHelp": false,
933
+ "multiple": false,
934
+ "type": "option"
935
+ },
936
+ "json": {
937
+ "char": "j",
938
+ "description": "Output in JSON format",
939
+ "name": "json",
940
+ "allowNo": false,
941
+ "type": "boolean"
942
+ }
943
+ },
944
+ "hasDynamicHelp": false,
945
+ "hiddenAliases": [],
946
+ "id": "page:list",
947
+ "pluginAlias": "@loopress/cli",
948
+ "pluginName": "@loopress/cli",
949
+ "pluginType": "core",
950
+ "strict": true,
951
+ "enableJsonFlag": false,
952
+ "dryRunFlag": {
953
+ "dry-run": {
954
+ "char": "d",
955
+ "description": "Show what would change without making changes",
956
+ "allowNo": false,
957
+ "type": "boolean"
958
+ }
959
+ },
960
+ "yesFlag": {
961
+ "yes": {
962
+ "char": "y",
963
+ "description": "Answer yes to confirmation prompts",
964
+ "allowNo": false,
965
+ "type": "boolean"
966
+ }
967
+ },
968
+ "isESM": true,
969
+ "relativePath": [
970
+ "dist",
971
+ "commands",
972
+ "page",
973
+ "list.js"
974
+ ]
975
+ },
976
+ "page:pull": {
977
+ "aliases": [],
978
+ "args": {
979
+ "path": {
980
+ "description": "Path to pages directory (overrides project config)",
981
+ "name": "path"
982
+ }
983
+ },
984
+ "description": "Pull pages from WordPress",
985
+ "examples": [
986
+ "$ lps page pull"
987
+ ],
988
+ "flags": {
989
+ "env": {
990
+ "description": "Target environment by name, overriding the globally active one (lps project switch)",
991
+ "name": "env",
992
+ "hasDynamicHelp": false,
993
+ "multiple": false,
994
+ "type": "option"
995
+ },
996
+ "dry-run": {
997
+ "char": "d",
998
+ "description": "Show what would change without making changes",
999
+ "name": "dry-run",
1000
+ "allowNo": false,
1001
+ "type": "boolean"
1002
+ },
1003
+ "yes": {
1004
+ "char": "y",
1005
+ "description": "Answer yes to confirmation prompts",
1006
+ "name": "yes",
1007
+ "allowNo": false,
1008
+ "type": "boolean"
1009
+ }
1010
+ },
1011
+ "hasDynamicHelp": false,
1012
+ "hiddenAliases": [],
1013
+ "id": "page:pull",
1014
+ "pluginAlias": "@loopress/cli",
1015
+ "pluginName": "@loopress/cli",
1016
+ "pluginType": "core",
1017
+ "strict": true,
1018
+ "enableJsonFlag": false,
1019
+ "dryRunFlag": {
1020
+ "dry-run": {
1021
+ "char": "d",
1022
+ "description": "Show what would change without making changes",
1023
+ "allowNo": false,
1024
+ "type": "boolean"
1025
+ }
1026
+ },
1027
+ "yesFlag": {
1028
+ "yes": {
1029
+ "char": "y",
1030
+ "description": "Answer yes to confirmation prompts",
1031
+ "allowNo": false,
1032
+ "type": "boolean"
1033
+ }
1034
+ },
1035
+ "isESM": true,
1036
+ "relativePath": [
1037
+ "dist",
1038
+ "commands",
1039
+ "page",
1040
+ "pull.js"
1041
+ ]
1042
+ },
1043
+ "page:push": {
1044
+ "aliases": [],
1045
+ "args": {
1046
+ "path": {
1047
+ "description": "Path to pages directory (overrides project config)",
1048
+ "name": "path"
1049
+ }
1050
+ },
1051
+ "description": "Push pages to WordPress. Local files created or updated remotely are renamed on disk to the `<id>-<slug>` convention.",
1052
+ "examples": [
1053
+ "$ lps page push"
1054
+ ],
1055
+ "flags": {
1056
+ "env": {
1057
+ "description": "Target environment by name, overriding the globally active one (lps project switch)",
1058
+ "name": "env",
1059
+ "hasDynamicHelp": false,
1060
+ "multiple": false,
1061
+ "type": "option"
1062
+ },
1063
+ "dry-run": {
1064
+ "char": "d",
1065
+ "description": "Show what would change without making changes",
1066
+ "name": "dry-run",
1067
+ "allowNo": false,
1068
+ "type": "boolean"
1069
+ },
1070
+ "yes": {
1071
+ "char": "y",
1072
+ "description": "Answer yes to confirmation prompts",
1073
+ "name": "yes",
1074
+ "allowNo": false,
1075
+ "type": "boolean"
1076
+ }
1077
+ },
1078
+ "hasDynamicHelp": false,
1079
+ "hiddenAliases": [],
1080
+ "id": "page:push",
1081
+ "pluginAlias": "@loopress/cli",
1082
+ "pluginName": "@loopress/cli",
1083
+ "pluginType": "core",
1084
+ "strict": true,
1085
+ "isESM": true,
1086
+ "relativePath": [
1087
+ "dist",
1088
+ "commands",
1089
+ "page",
1090
+ "push.js"
1091
+ ]
1092
+ },
891
1093
  "plugin:add": {
892
1094
  "aliases": [],
893
1095
  "args": {
@@ -1180,6 +1382,55 @@
1180
1382
  "remove.js"
1181
1383
  ]
1182
1384
  },
1385
+ "project:rotate": {
1386
+ "aliases": [],
1387
+ "args": {},
1388
+ "description": "Rotate the WordPress application password for the current (or --env) environment",
1389
+ "examples": [
1390
+ "$ lps project rotate",
1391
+ "$ lps project rotate --env staging"
1392
+ ],
1393
+ "flags": {
1394
+ "env": {
1395
+ "description": "Target environment by name, overriding the globally active one (lps project switch)",
1396
+ "name": "env",
1397
+ "hasDynamicHelp": false,
1398
+ "multiple": false,
1399
+ "type": "option"
1400
+ }
1401
+ },
1402
+ "hasDynamicHelp": false,
1403
+ "hiddenAliases": [],
1404
+ "id": "project:rotate",
1405
+ "pluginAlias": "@loopress/cli",
1406
+ "pluginName": "@loopress/cli",
1407
+ "pluginType": "core",
1408
+ "strict": true,
1409
+ "enableJsonFlag": false,
1410
+ "dryRunFlag": {
1411
+ "dry-run": {
1412
+ "char": "d",
1413
+ "description": "Show what would change without making changes",
1414
+ "allowNo": false,
1415
+ "type": "boolean"
1416
+ }
1417
+ },
1418
+ "yesFlag": {
1419
+ "yes": {
1420
+ "char": "y",
1421
+ "description": "Answer yes to confirmation prompts",
1422
+ "allowNo": false,
1423
+ "type": "boolean"
1424
+ }
1425
+ },
1426
+ "isESM": true,
1427
+ "relativePath": [
1428
+ "dist",
1429
+ "commands",
1430
+ "project",
1431
+ "rotate.js"
1432
+ ]
1433
+ },
1183
1434
  "project:switch": {
1184
1435
  "aliases": [],
1185
1436
  "args": {},
@@ -1645,5 +1896,5 @@
1645
1896
  ]
1646
1897
  }
1647
1898
  },
1648
- "version": "0.19.0"
1899
+ "version": "0.20.0"
1649
1900
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@loopress/cli",
3
3
  "description": "CLI tool for syncing WordPress code snippets, plugins, and Composer dependencies via the REST API",
4
- "version": "0.19.0",
4
+ "version": "0.20.0",
5
5
  "author": "jean-smaug",
6
6
  "bin": {
7
7
  "loopress": "bin/run.js",
@@ -13,13 +13,14 @@
13
13
  "dependencies": {
14
14
  "@inquirer/prompts": "^8.5.2",
15
15
  "@oclif/core": "^4.13.0",
16
- "@oclif/plugin-help": "^6.2.53",
17
- "@oclif/plugin-warn-if-update-available": "^3.1.68",
18
- "@sentry/node": "^10.67.0",
16
+ "@oclif/plugin-help": "^6.2.55",
17
+ "@oclif/plugin-warn-if-update-available": "^3.1.70",
18
+ "@sentry/node": "^10.68.0",
19
19
  "glob": "13.0.6",
20
20
  "got": "^15.1.0",
21
21
  "listr2": "^11.0.0",
22
22
  "open": "^11.0.0",
23
+ "playwright-core": "^1.56.0",
23
24
  "slugify": "^1.6.9",
24
25
  "write-file-atomic": "^8.0.0"
25
26
  },
@@ -31,11 +32,11 @@
31
32
  "@types/node": "26.1.1",
32
33
  "@types/write-file-atomic": "^4.0.3",
33
34
  "@vitest/coverage-v8": "4.1.10",
34
- "eslint": "^9",
35
- "eslint-config-oclif": "^6.0.177",
35
+ "eslint": "^10.8.0",
36
+ "eslint-config-oclif": "^6.0.179",
36
37
  "eslint-config-prettier": "^10",
37
38
  "json-schema-to-typescript": "^15.0.4",
38
- "oclif": "^4.23.28",
39
+ "oclif": "^4.23.29",
39
40
  "prettier": "3.9.6",
40
41
  "shx": "^0.4.0",
41
42
  "ts-node": "10.9.2",
@@ -34,6 +34,11 @@
34
34
  "description": "Directory where forms (WPForms today) are read from and written to, relative to rootDir.",
35
35
  "default": "forms"
36
36
  },
37
+ "pageDir": {
38
+ "type": "string",
39
+ "description": "Directory where WordPress pages are read from and written to, relative to rootDir.",
40
+ "default": "pages"
41
+ },
37
42
  "apiDir": {
38
43
  "type": "string",
39
44
  "description": "Directory where custom API route files are read from and written to, relative to rootDir.",