@loopress/cli 0.26.0 → 0.28.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 (87) hide show
  1. package/README.md +275 -177
  2. package/dist/commands/app/pull.js +17 -4
  3. package/dist/commands/app/push.d.ts +3 -0
  4. package/dist/commands/app/push.js +29 -5
  5. package/dist/commands/composer/push.js +1 -19
  6. package/dist/commands/diff.js +1 -1
  7. package/dist/commands/init.js +7 -1
  8. package/dist/commands/page/push.js +2 -2
  9. package/dist/commands/plugin/add.d.ts +8 -1
  10. package/dist/commands/plugin/add.js +10 -5
  11. package/dist/commands/plugin/audit.js +4 -5
  12. package/dist/commands/plugin/pull.d.ts +3 -1
  13. package/dist/commands/plugin/pull.js +17 -9
  14. package/dist/commands/plugin/push.d.ts +5 -1
  15. package/dist/commands/plugin/push.js +90 -37
  16. package/dist/commands/plugin/status.d.ts +1 -0
  17. package/dist/commands/plugin/status.js +21 -2
  18. package/dist/commands/project/config.js +12 -8
  19. package/dist/commands/promote.d.ts +2 -0
  20. package/dist/commands/promote.js +70 -8
  21. package/dist/commands/pull.js +8 -4
  22. package/dist/commands/push.d.ts +1 -0
  23. package/dist/commands/push.js +42 -4
  24. package/dist/commands/template/diff.d.ts +10 -0
  25. package/dist/commands/template/diff.js +13 -0
  26. package/dist/commands/template/list.d.ts +13 -0
  27. package/dist/commands/template/list.js +23 -0
  28. package/dist/commands/template/push.d.ts +20 -0
  29. package/dist/commands/template/push.js +42 -0
  30. package/dist/commands/theme/add.d.ts +8 -1
  31. package/dist/commands/theme/add.js +4 -2
  32. package/dist/commands/theme/pull.d.ts +3 -1
  33. package/dist/commands/theme/pull.js +10 -4
  34. package/dist/commands/theme/style/diff.d.ts +2 -0
  35. package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
  36. package/dist/commands/{theme-styles → theme/style}/pull.d.ts +1 -1
  37. package/dist/commands/{theme-styles → theme/style}/pull.js +3 -3
  38. package/dist/commands/{theme-styles → theme/style}/push.d.ts +1 -1
  39. package/dist/commands/{theme-styles → theme/style}/push.js +5 -5
  40. package/dist/commands/theme/style/rollback.d.ts +2 -0
  41. package/dist/commands/theme/style/rollback.js +6 -0
  42. package/dist/lib/api-client.js +26 -1
  43. package/dist/lib/app-build.d.ts +5 -0
  44. package/dist/lib/app-build.js +69 -0
  45. package/dist/lib/app-manifest.d.ts +4 -0
  46. package/dist/lib/app-manifest.js +8 -0
  47. package/dist/lib/base.js +11 -2
  48. package/dist/lib/bootstrap-full-install.js +45 -14
  49. package/dist/lib/dev-watch.d.ts +1 -1
  50. package/dist/lib/dev-watch.js +4 -1
  51. package/dist/lib/diff-command.d.ts +1 -0
  52. package/dist/lib/diff-command.js +6 -4
  53. package/dist/lib/json-delegation.d.ts +1 -0
  54. package/dist/lib/json-delegation.js +18 -0
  55. package/dist/lib/load-snippets.js +1 -1
  56. package/dist/lib/php-files-command.js +4 -3
  57. package/dist/lib/readdir-tolerant.js +15 -5
  58. package/dist/lib/resource-state.js +43 -1
  59. package/dist/lib/rollback-command.d.ts +1 -0
  60. package/dist/lib/rollback-command.js +8 -6
  61. package/dist/lib/rotate-app-password.js +2 -2
  62. package/dist/lib/snapshot-store.js +14 -0
  63. package/dist/lib/validate-local.js +34 -7
  64. package/dist/lib/wp-client.d.ts +4 -1
  65. package/dist/lib/wp-client.js +56 -3
  66. package/dist/types/plugin.d.ts +8 -1
  67. package/dist/types/plugin.js +4 -1
  68. package/dist/types/project-config.generated.d.ts +19 -2
  69. package/dist/utils/composer.d.ts +14 -0
  70. package/dist/utils/composer.js +136 -0
  71. package/dist/utils/loopress-config.d.ts +1 -0
  72. package/dist/utils/loopress-config.js +14 -0
  73. package/dist/utils/page-format.d.ts +12 -0
  74. package/dist/utils/page-format.js +41 -26
  75. package/dist/utils/plugins.d.ts +9 -4
  76. package/dist/utils/plugins.js +16 -7
  77. package/dist/utils/resource-dirs.d.ts +2 -0
  78. package/dist/utils/resource-dirs.js +4 -0
  79. package/dist/utils/seo-format.d.ts +2 -2
  80. package/dist/utils/template-format.d.ts +30 -0
  81. package/dist/utils/template-format.js +36 -0
  82. package/oclif.manifest.json +536 -320
  83. package/package.json +7 -4
  84. package/schemas/project-config.schema.json +25 -4
  85. package/dist/commands/theme-styles/diff.d.ts +0 -2
  86. package/dist/commands/theme-styles/rollback.d.ts +0 -2
  87. package/dist/commands/theme-styles/rollback.js +0 -5
@@ -70,7 +70,7 @@ async function loadSnippet(path, file, onSkip) {
70
70
  };
71
71
  }
72
72
  // Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
73
- // (publishes to the Loopress api as a shared source) — both read the same local `snippets/`
73
+ // (publishes to the Loopress api as a shared source): both read the same local `snippets/`
74
74
  // directory the same way. `onSkip` is optional so callers that don't care about per-file
75
75
  // warnings (e.g. `snippet publish`) can ignore them; skipped files are simply left out either way.
76
76
  export async function loadSnippets(path, onSkip) {
@@ -341,9 +341,10 @@ export const API_FILES_RESOURCE = {
341
341
  emptyListMessage: 'No API route files found',
342
342
  endpoint: 'loopress/v1/api-files',
343
343
  // Mirrors ApiFilesController::isValidFilename(): kebab-case segments or a bracketed dynamic
344
- // segment like [order_id], joined by '/'.
345
- filenamePattern: /^(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/,
346
- invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens, or a bracketed dynamic segment like "[order_id]" (e.g. "invoice-pdf/[order_id].php")',
344
+ // segment like [order_id], joined by '/', and never a root "index" (api/index.php is
345
+ // reserved; a nested orders/index.php is the /orders route).
346
+ filenamePattern: /^(?!index$)(?:[a-z0-9-]+|\[[A-Za-z_]\w*\])(?:\/(?:[a-z0-9-]+|\[[A-Za-z_]\w*\]))*$/,
347
+ invalidFilenameHint: 'each path segment must be lowercase letters, digits, and hyphens, or a bracketed dynamic segment like "[order_id]" (e.g. "invoice-pdf/[order_id].php"), and a root "index.php" is reserved: name the file, or nest it (orders/index.php is the /orders route)',
347
348
  label: 'API routes',
348
349
  listDescription: 'List custom API route files from WordPress',
349
350
  noun: 'route file',
@@ -1,4 +1,4 @@
1
- import { readdir } from 'node:fs/promises';
1
+ import { readdir, stat } from 'node:fs/promises';
2
2
  import { join, relative, sep } from 'node:path';
3
3
  export async function readdirTolerant(dir, options) {
4
4
  try {
@@ -16,10 +16,20 @@ export async function readdirTolerant(dir, options) {
16
16
  // never the OS separator: callers match these identities against server-sent filenames
17
17
  // (which are always '/'-joined) or embed them in an API payload, so a Windows run must
18
18
  // produce the exact same strings as a POSIX one. ENOENT on `dir` itself yields no files,
19
- // same tolerance as readdirTolerant.
19
+ // same tolerance as readdirTolerant. A symlink to a file counts as that file (a project can
20
+ // link a route in from a shared checkout); a broken one, or one to a directory, is skipped.
20
21
  export async function walkFiles(dir) {
21
22
  const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
22
- return entries
23
- .filter((entry) => entry.isFile())
24
- .map((entry) => relative(dir, join(entry.parentPath, entry.name)).split(sep).join('/'));
23
+ const files = [];
24
+ for (const entry of entries) {
25
+ const path = join(entry.parentPath, entry.name);
26
+ if (entry.isFile() || (entry.isSymbolicLink() && (await isFile(path))))
27
+ files.push(relative(dir, path).split(sep).join('/'));
28
+ }
29
+ return files;
30
+ }
31
+ async function isFile(path) {
32
+ return stat(path)
33
+ .then((s) => s.isFile())
34
+ .catch(() => false);
25
35
  }
@@ -4,9 +4,10 @@ import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js
4
4
  import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
5
5
  import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
6
6
  import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
7
- import { formatPageProblems, PAGES_ENDPOINT, readLocalPages } from '../utils/page-format.js';
7
+ import { formatPageProblems, PAGES_ENDPOINT, readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
8
8
  import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
9
9
  import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
10
+ import { CHILD_THEME_ENDPOINT, parsePartFile, parseTemplateFile } from '../utils/template-format.js';
10
11
  import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
11
12
  import { loadFiles } from './load-files.js';
12
13
  import { loadSnippets } from './load-snippets.js';
@@ -217,6 +218,45 @@ const pageProvider = {
217
218
  resource: 'page',
218
219
  title: 'Pages',
219
220
  };
221
+ // ---- Templates and parts ---------------------------------------------------------------
222
+ // Both live in the Loopress child theme (ChildThemeController), one GET answers for both. A
223
+ // template or part edited in the Site Editor gets `siteEditor: 'customized'` on the remote side
224
+ // only, so it always shows as drift: its database copy is what the site renders, not the file.
225
+ async function childThemeState(wp, kind) {
226
+ const child = await wp.get(CHILD_THEME_ENDPOINT);
227
+ const files = child[kind];
228
+ const state = new Map(files.map(({ slug, ...rest }) => [slug, rest]));
229
+ for (const entry of child.customized) {
230
+ const [dir, slug] = entry.split('/', 2);
231
+ if (dir === kind)
232
+ state.set(slug, { ...state.get(slug), siteEditor: 'customized' });
233
+ }
234
+ return state;
235
+ }
236
+ const templateProvider = {
237
+ dirKind: 'template',
238
+ async local(dir) {
239
+ const { files, problems } = await readLocalHtmlFiles(dir, parseTemplateFile);
240
+ if (problems.length > 0)
241
+ throw new Error(formatPageProblems(problems));
242
+ return new Map(files.map(({ slug, ...rest }) => [slug, rest]));
243
+ },
244
+ remote: async (wp) => childThemeState(wp, 'templates'),
245
+ resource: 'template',
246
+ title: 'Templates',
247
+ };
248
+ const partProvider = {
249
+ dirKind: 'part',
250
+ async local(dir) {
251
+ const { files, problems } = await readLocalHtmlFiles(dir, parsePartFile);
252
+ if (problems.length > 0)
253
+ throw new Error(formatPageProblems(problems));
254
+ return new Map(files.map(({ slug, ...rest }) => [slug, rest]));
255
+ },
256
+ remote: async (wp) => childThemeState(wp, 'parts'),
257
+ resource: 'part',
258
+ title: 'Template parts',
259
+ };
220
260
  // ---- SEO --------------------------------------------------------------------------------
221
261
  function canonicalRedirect(redirect) {
222
262
  return omit(redirect, REDIRECT_VOLATILE_KEYS);
@@ -426,6 +466,8 @@ export const RESOURCE_STATE_PROVIDERS = [
426
466
  acfProvider,
427
467
  apiProvider,
428
468
  hookProvider,
469
+ templateProvider,
470
+ partProvider,
429
471
  pageProvider,
430
472
  seoProvider,
431
473
  menuProvider,
@@ -16,6 +16,7 @@ export type RollbackResult = {
16
16
  status: 'dry-run' | 'listed' | 'no-op' | 'success';
17
17
  };
18
18
  export declare function resourceRollbackCommand(resource: string, options: {
19
+ command?: string;
19
20
  description: string;
20
21
  pathNoun: string;
21
22
  }): typeof LoopressCommand;
@@ -33,7 +33,9 @@ function indentPatch(patch) {
33
33
  // anything, this runs the equivalent of `lps <resource> diff` between the environment's current
34
34
  // state and the state the original push expected to leave behind, and requires explicit
35
35
  // confirmation (the same interactive/`--yes` shape as guardProductionPush) when they differ.
36
+ // `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-styles).
36
37
  export function resourceRollbackCommand(resource, options) {
38
+ const command = options.command ?? resource;
37
39
  class ResourceRollback extends LoopressCommand {
38
40
  static args = {
39
41
  path: Args.string({ description: `Path to ${options.pathNoun} (overrides project config)` }),
@@ -41,10 +43,10 @@ export function resourceRollbackCommand(resource, options) {
41
43
  static description = options.description;
42
44
  static enableJsonFlag = true;
43
45
  static examples = [
44
- `$ lps ${resource} rollback`,
45
- `$ lps ${resource} rollback --list`,
46
- `$ lps ${resource} rollback --to 1732000000000`,
47
- `$ lps ${resource} rollback --dry-run`,
46
+ `$ lps ${command} rollback`,
47
+ `$ lps ${command} rollback --list`,
48
+ `$ lps ${command} rollback --to 1732000000000`,
49
+ `$ lps ${command} rollback --dry-run`,
48
50
  ];
49
51
  static flags = {
50
52
  ...LoopressCommand.dryRunFlag,
@@ -105,7 +107,7 @@ export function resourceRollbackCommand(resource, options) {
105
107
  // an item present now but absent from the snapshot stays present after "restoring" it.
106
108
  if (restoreDiff.removed.length > 0) {
107
109
  this.warn(`${pluralize(restoreDiff.removed.length, 'item')} present now but not in this snapshot will NOT be removed ` +
108
- `(${restoreDiff.removed.join(', ')}): restoring only creates and updates, the same as \`lps ${resource} push\`. Remove ${restoreDiff.removed.length === 1 ? 'it' : 'them'} by hand if that's part of undoing the original push.`);
110
+ `(${restoreDiff.removed.join(', ')}): restoring only creates and updates, the same as \`lps ${command} push\`. Remove ${restoreDiff.removed.length === 1 ? 'it' : 'them'} by hand if that's part of undoing the original push.`);
109
111
  }
110
112
  if (this.dryRun) {
111
113
  return { drift: driftSummary, id: snapshot.id, restored: summarize(restoreDiff), status: 'dry-run' };
@@ -125,7 +127,7 @@ export function resourceRollbackCommand(resource, options) {
125
127
  // production guard and the drift confirmation above already covered what the delegated
126
128
  // push's own guard would ask again, same reasoning as the top-level `lps push`
127
129
  // delegating to each resource's push command in commands/push.ts.
128
- await this.config.runCommand(`${resource}:push`, [tmpDir, '--env', this.siteConfig.name, '--yes']);
130
+ await this.config.runCommand(`${command.replaceAll(' ', ':')}:push`, [tmpDir, '--env', this.siteConfig.name, '--yes']);
129
131
  }
130
132
  finally {
131
133
  // A cleanup failure here must never hide a real error from the push above: warn about
@@ -8,14 +8,14 @@ export function isAppPasswordStale(addedAt) {
8
8
  // the old one. Reversing this order risks locking the user out if the new one is somehow bad.
9
9
  export async function rotateAppPassword(env) {
10
10
  const [user] = env.token.split(':', 1);
11
- const oldClient = new WpClient(env.url, env.token);
11
+ const oldClient = new WpClient(env.url, env.token, env.name);
12
12
  const old = await oldClient.get(`${APPLICATION_PASSWORDS_PATH}/introspect`);
13
13
  const created = await oldClient.post(APPLICATION_PASSWORDS_PATH, { name: 'Loopress' });
14
14
  if (!created.password) {
15
15
  throw new Error(`WordPress did not return a password when creating the new application password on ${env.url}.`);
16
16
  }
17
17
  const newToken = `${user}:${created.password}`;
18
- const newClient = new WpClient(env.url, newToken);
18
+ const newClient = new WpClient(env.url, newToken, env.name);
19
19
  try {
20
20
  await newClient.get(`${APPLICATION_PASSWORDS_PATH}/introspect`);
21
21
  }
@@ -49,6 +49,7 @@ export async function writeSnapshot(options) {
49
49
  const { afterState, beforeState, environment, resource, retain = SNAPSHOT_RETENTION, rootDir } = options;
50
50
  const dir = snapshotsDir(rootDir, resource, environment);
51
51
  await mkdir(dir, { recursive: true });
52
+ await ignoreCacheDirInGit(rootDir);
52
53
  const snapshot = {
53
54
  afterState: stateToRecord(afterState),
54
55
  beforeState: stateToRecord(beforeState),
@@ -72,6 +73,19 @@ export async function writeSnapshot(options) {
72
73
  await pruneSnapshots(rootDir, resource, environment, retain);
73
74
  return String(id);
74
75
  }
76
+ // `.loopress/` lives inside the user's project and holds the environment's full remote state
77
+ // (snippet code, form definitions, option values): a `git add .` must never pick it up. The
78
+ // directory ignores itself, the way `.pytest_cache` does, instead of relying on every project
79
+ // to add it to its own .gitignore. `wx` so a hand-edited file is never overwritten.
80
+ async function ignoreCacheDirInGit(rootDir) {
81
+ try {
82
+ await writeFile(join(rootDir, '.loopress', '.gitignore'), '# Written by the Loopress CLI: local rollback snapshots, never commit them.\n*\n', { flag: 'wx' });
83
+ }
84
+ catch (error) {
85
+ if (!isEexist(error))
86
+ throw error;
87
+ }
88
+ }
75
89
  // Newest first. A missing snapshots directory (never pushed, or pruned to nothing) reads as
76
90
  // "no snapshots", not an error, the same ENOENT tolerance every resource-state provider gives
77
91
  // a never-pulled local directory.
@@ -1,22 +1,28 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { configManager } from '../config/project-config.manager.js';
4
+ import { parseLocalOption } from '../utils/option-format.js';
4
5
  import { readLocalPages } from '../utils/page-format.js';
5
6
  import { parseType } from '../utils/snippet-format.js';
7
+ import { readLocalTemplates } from '../utils/template-format.js';
6
8
  import { loadSnippets } from './load-snippets.js';
7
9
  import { readdirTolerant } from './readdir-tolerant.js';
8
10
  // Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
9
- // and api routes (`.php`, not JSON) are checked separately.
10
- const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir', 'menuDir'];
11
+ // and api routes (`.php`, not JSON) are checked separately, options too (a stricter shape).
12
+ const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir', 'menuDir', 'themeStylesDir'];
11
13
  const DEFAULT_DIR = {
12
14
  acfDir: 'acf',
13
15
  apiDir: 'api',
14
16
  formDir: 'forms',
15
17
  hooksDir: 'hooks',
16
18
  menuDir: 'menus',
19
+ optionsDir: 'options',
17
20
  pageDir: 'pages',
21
+ partDir: 'parts',
18
22
  seoDir: 'seo',
19
23
  snippetsDir: 'snippets',
24
+ templateDir: 'templates',
25
+ themeStylesDir: 'theme',
20
26
  };
21
27
  // Inspects the local tracked files under `cwd` without contacting WordPress: JSON files parse
22
28
  // and have the right shape, snippet sidecars use a known type, `loopress.json` points at a
@@ -37,12 +43,18 @@ export async function validateLocal(cwd) {
37
43
  for (const key of JSON_RESOURCE_DIRS) {
38
44
  checked += await checkJsonDir(resolve(key), problems);
39
45
  }
46
+ // Same reader `option push` uses: a file missing "name", "autoload" or "value" is skipped
47
+ // there with a warning, so it's a problem here.
48
+ checked += await checkJsonDir(resolve('optionsDir'), problems, parseLocalOption);
40
49
  checked += await checkSnippets(resolve('snippetsDir'), problems);
41
50
  checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty');
42
51
  checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty');
43
52
  const pages = await readLocalPages(resolve('pageDir'));
44
53
  checked += pages.pages.length + pages.problems.length;
45
54
  problems.push(...pages.problems);
55
+ const templates = await readLocalTemplates(resolve('templateDir'), resolve('partDir'));
56
+ checked += templates.templates.length + templates.parts.length + templates.problems.length;
57
+ problems.push(...templates.problems);
46
58
  return { checked, problems, valid: problems.length === 0 };
47
59
  }
48
60
  // Recursive: both api/ and hooks/ files can live in a nested path-segment directory
@@ -91,15 +103,23 @@ async function checkComposerJson(cwd, problems) {
91
103
  }
92
104
  return 1;
93
105
  }
94
- async function checkJsonDir(dir, problems) {
95
- const files = (await readdirTolerant(dir)).filter((file) => file.endsWith('.json'));
106
+ // Recursive: ACF keeps one subdirectory per object type (acf/field-groups/*.json) and SEO its
107
+ // post meta and redirects (seo/post-meta/<type>/*.json, seo/redirects/*.json); a top-level-only
108
+ // scan silently never checked any of those. `shape` runs on files that are JSON objects and
109
+ // throws to report a resource-specific problem.
110
+ async function checkJsonDir(dir, problems, shape) {
111
+ const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
96
112
  let checked = 0;
97
- for (const file of files) {
113
+ for (const entry of entries) {
114
+ if (!entry.isFile() || !entry.name.endsWith('.json'))
115
+ continue;
98
116
  checked += 1;
99
- const filePath = join(dir, file);
117
+ // parentPath, not dir: see checkPhpDir.
118
+ const filePath = join(entry.parentPath, entry.name);
119
+ const raw = await readFile(filePath, 'utf8');
100
120
  let parsed;
101
121
  try {
102
- parsed = JSON.parse(await readFile(filePath, 'utf8'));
122
+ parsed = JSON.parse(raw);
103
123
  }
104
124
  catch (error) {
105
125
  problems.push({ file: filePath, message: `not valid JSON: ${error.message}` });
@@ -107,6 +127,13 @@ async function checkJsonDir(dir, problems) {
107
127
  }
108
128
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
109
129
  problems.push({ file: filePath, message: 'expected a JSON object' });
130
+ continue;
131
+ }
132
+ try {
133
+ shape?.(raw);
134
+ }
135
+ catch (error) {
136
+ problems.push({ file: filePath, message: error.message });
110
137
  }
111
138
  }
112
139
  return checked;
@@ -1,4 +1,7 @@
1
1
  export declare const REQUEST_TIMEOUT_MS = 30000;
2
+ export declare const LOCAL_ENVIRONMENT_NAME = "local";
3
+ export declare function isLocalEnvironment(environmentName: string | undefined): boolean;
4
+ export declare function assertSecureSiteUrl(siteUrl: string, environmentName: string | undefined): void;
2
5
  /**
3
6
  * HTTP client for a WordPress site's REST API.
4
7
  * Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/snippets` or `wp/v2/plugins`.
@@ -6,7 +9,7 @@ export declare const REQUEST_TIMEOUT_MS = 30000;
6
9
  export declare class WpClient {
7
10
  private readonly siteUrl;
8
11
  private readonly client;
9
- constructor(siteUrl: string, token: string);
12
+ constructor(siteUrl: string, token: string, environmentName?: string);
10
13
  delete<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
11
14
  get<T>(path: string, options?: RequestOptions): Promise<T>;
12
15
  getAll<T>(path: string, options?: RequestOptions & {
@@ -1,6 +1,29 @@
1
1
  import got from 'got';
2
2
  import { Buffer } from 'node:buffer';
3
3
  export const REQUEST_TIMEOUT_MS = 30_000;
4
+ // The one environment allowed to talk plain HTTP: Loopress's local dev environment, identified
5
+ // by its config name (the same "local" environment `lps dev` targets), never by the URL's host.
6
+ // Locality is a property of the configured environment, not of a hostname, so a real remote site
7
+ // can never opt into cleartext just by resolving to a `.local`/loopback-looking name.
8
+ export const LOCAL_ENVIRONMENT_NAME = 'local';
9
+ export function isLocalEnvironment(environmentName) {
10
+ return environmentName === LOCAL_ENVIRONMENT_NAME;
11
+ }
12
+ // Throws when a URL would send credentials in cleartext: http:// for any environment other than
13
+ // the local one. The trust boundary is here, at the client, so a hand-edited config.json or
14
+ // loopress.json cannot bypass the check the interactive prompt also applies.
15
+ export function assertSecureSiteUrl(siteUrl, environmentName) {
16
+ let parsed;
17
+ try {
18
+ parsed = new URL(siteUrl);
19
+ }
20
+ catch {
21
+ throw new Error(`Invalid site URL: ${siteUrl}`);
22
+ }
23
+ if (parsed.protocol === 'http:' && !isLocalEnvironment(environmentName)) {
24
+ throw new Error(`Refusing to send credentials over plain HTTP for the "${environmentName ?? 'unknown'}" environment: the Application Password would travel in cleartext. Use HTTPS. Plain HTTP is allowed only for the "${LOCAL_ENVIRONMENT_NAME}" environment.`);
25
+ }
26
+ }
4
27
  /**
5
28
  * HTTP client for a WordPress site's REST API.
6
29
  * Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/snippets` or `wp/v2/plugins`.
@@ -8,8 +31,9 @@ export const REQUEST_TIMEOUT_MS = 30_000;
8
31
  export class WpClient {
9
32
  siteUrl;
10
33
  client;
11
- constructor(siteUrl, token) {
34
+ constructor(siteUrl, token, environmentName) {
12
35
  this.siteUrl = siteUrl;
36
+ assertSecureSiteUrl(siteUrl, environmentName);
13
37
  this.client = got.extend({
14
38
  headers: { Authorization: `Basic ${Buffer.from(token).toString('base64')}` },
15
39
  prefixUrl: `${siteUrl}/wp-json`,
@@ -107,6 +131,13 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
107
131
  const err = error;
108
132
  const status = err.response?.statusCode;
109
133
  if (status === 401 || status === 403) {
134
+ // A Loopress controller's own `{"error": ...}` on a 403 is a deliberate refusal of this one
135
+ // request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
136
+ // pointing at `lps project config` there sends the user the wrong way. WordPress core's own
137
+ // auth failures use `{code, message}` instead and keep the credentials hint.
138
+ const refusal = extractLoopressError(err.response?.body);
139
+ if (refusal)
140
+ return `Request refused (${status}) on ${url}: ${refusal}`;
110
141
  return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
111
142
  }
112
143
  if (status === 404) {
@@ -124,6 +155,28 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
124
155
  }
125
156
  return `Request to ${url} failed: ${err.message ?? String(error)}`;
126
157
  }
158
+ function extractLoopressError(body) {
159
+ if (!body)
160
+ return undefined;
161
+ try {
162
+ const { error } = JSON.parse(body);
163
+ return typeof error === 'string' && error.trim() ? decodeEntities(error) : undefined;
164
+ }
165
+ catch {
166
+ return undefined;
167
+ }
168
+ }
169
+ // Plugin exception messages go through esc_html() (WordPress coding standards require it for
170
+ // anything thrown), so quotes and ampersands reach the JSON body as entities. They're meant for
171
+ // a terminal here, not an HTML page. `&amp;` last, so "&amp;quot;" stays a literal "&quot;".
172
+ function decodeEntities(text) {
173
+ return text
174
+ .replaceAll('&quot;', '"')
175
+ .replaceAll('&#039;', "'")
176
+ .replaceAll('&lt;', '<')
177
+ .replaceAll('&gt;', '>')
178
+ .replaceAll('&amp;', '&');
179
+ }
127
180
  // The Loopress plugin's own controllers reply with `{"error": "..."}`; a WP_Error-based
128
181
  // core response (e.g. an uncaught fatal formatted by WordPress itself) uses `{"message": "..."}`.
129
182
  // Surfacing this is what makes a deliberately clear server-side error (e.g. "Multiple snippet
@@ -131,7 +184,7 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
131
184
  //
132
185
  // Some controllers (ComposerController::sync(), notably) pair a short, generic `error` (e.g.
133
186
  // "Sync failed.") with the real detail in a separate `output` field (the raw Composer trace),
134
- // specifically so a caller that only reads `error` doesn't see it — which is exactly what this
187
+ // specifically so a caller that only reads `error` doesn't see it, which is exactly what this
135
188
  // function used to do, hiding the one piece of text that actually explains the failure.
136
189
  function extractServerErrorMessage(body) {
137
190
  if (!body)
@@ -145,7 +198,7 @@ function extractServerErrorMessage(body) {
145
198
  if (parsed.code === 'rest_no_route')
146
199
  return undefined;
147
200
  const reason = parsed.error ?? parsed.message;
148
- const summary = typeof reason === 'string' && reason.trim() ? reason : undefined;
201
+ const summary = typeof reason === 'string' && reason.trim() ? decodeEntities(reason) : undefined;
149
202
  const detail = typeof parsed.output === 'string' && parsed.output.trim() ? parsed.output.trim() : undefined;
150
203
  if (summary && detail)
151
204
  return `${summary}\n${detail}`;
@@ -12,4 +12,11 @@ export type WpNativePlugin = {
12
12
  status: 'active' | 'inactive' | 'network-active';
13
13
  version: string;
14
14
  };
15
- export type PluginManifest = Record<string, string>;
15
+ export type PluginPin = string | {
16
+ active?: boolean;
17
+ version: string;
18
+ };
19
+ export type PluginManifest = Record<string, PluginPin>;
20
+ export declare const pinVersion: (pin: PluginPin) => string;
21
+ export declare const pinActive: (pin: PluginPin) => boolean;
22
+ export declare const pinVersions: (manifest: PluginManifest) => Record<string, string>;
@@ -1 +1,4 @@
1
- export {};
1
+ export const pinVersion = (pin) => (typeof pin === 'string' ? pin : pin.version);
2
+ export const pinActive = (pin) => typeof pin === 'string' || pin.active !== false;
3
+ // The version-only view the Composer sync endpoint expects.
4
+ export const pinVersions = (manifest) => Object.fromEntries(Object.entries(manifest).map(([slug, pin]) => [slug, pinVersion(pin)]));
@@ -39,6 +39,14 @@ export interface LoopressProjectConfiguration {
39
39
  * Directory where static HTML pages (one <slug>.html per WordPress page, managed by Loopress) are read from, relative to rootDir.
40
40
  */
41
41
  pageDir?: string;
42
+ /**
43
+ * Directory where block templates (one <slug>.html per template, written to the Loopress child theme of the active block theme) are read from, relative to rootDir. A static page uses one with its `template` header.
44
+ */
45
+ templateDir?: string;
46
+ /**
47
+ * Directory where block template parts (one <slug>.html per part, written to the Loopress child theme of the active block theme) are read from, relative to rootDir.
48
+ */
49
+ partDir?: string;
42
50
  /**
43
51
  * Directory where explicitly tracked WordPress options (wp_options rows) are read from and written to, relative to rootDir. Only options added via `lps option add` live here, this is never the whole options table.
44
52
  */
@@ -56,10 +64,19 @@ export interface LoopressProjectConfiguration {
56
64
  */
57
65
  projectId?: string;
58
66
  /**
59
- * WordPress.org plugins managed by Loopress, installed via Composer + WPackagist on the site. Keys are plugin slugs. A value is either an exact version to pin (e.g. "9.4.2") or "latest" to track the newest release on every push. Removing an entry uninstalls the plugin on the next push. Ignored when a composer.json exists in rootDir (that file becomes authoritative).
67
+ * WordPress.org plugins managed by Loopress, installed via Composer + WPackagist on the site. Keys are plugin slugs. A value is either an exact version to pin (e.g. "9.4.2") or "latest" to track the newest release on every push, and the plugin is kept active. An object with "active": false keeps the plugin installed but inactive. Removing an entry uninstalls the plugin on the next push. Ignored when a composer.json exists in rootDir (that file becomes authoritative).
60
68
  */
61
69
  plugins?: {
62
- [k: string]: string;
70
+ [k: string]: string | {
71
+ /**
72
+ * Exact version to pin, or "latest".
73
+ */
74
+ version: string;
75
+ /**
76
+ * false keeps the plugin installed but inactive. Defaults to true.
77
+ */
78
+ active?: boolean;
79
+ };
63
80
  };
64
81
  /**
65
82
  * WordPress.org themes managed by Loopress, installed via Composer + WPackagist on the site. Keys are theme slugs; values are an exact version or "latest". Loopress manages installed theme versions only, it never switches the active theme. Ignored when a composer.json exists in rootDir.
@@ -1,3 +1,5 @@
1
+ import { type SyncIntent } from './plugin-sync.js';
2
+ import { type MergeResult } from './plugins.js';
1
3
  export type ComposerJson = {
2
4
  config?: {
3
5
  'allow-plugins'?: Record<string, boolean>;
@@ -16,3 +18,15 @@ export type ComposerJson = {
16
18
  export declare function readComposerJson(): Promise<ComposerJson | null>;
17
19
  export declare function readComposerLock(): Promise<null | string>;
18
20
  export declare function getComposerManagedSlugs(composerJson: ComposerJson): string[];
21
+ export type ComposerPullResult = MergeResult<string> & {
22
+ skipped: Array<{
23
+ reason: string;
24
+ slug: string;
25
+ }>;
26
+ };
27
+ export declare function pullIntoComposerJson(path: string, kind: 'plugin' | 'theme', incoming: Record<string, string>, { dryRun, log }: {
28
+ dryRun: boolean;
29
+ log: (message: string) => void;
30
+ }): Promise<ComposerPullResult>;
31
+ export declare function toIntent(require: Record<string, string>): SyncIntent;
32
+ export declare function wpackagistRequire(composerJson: ComposerJson, kind: 'plugin' | 'theme'): Record<string, string>;