@loopress/cli 0.27.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 (75) hide show
  1. package/README.md +267 -175
  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 +2 -0
  8. package/dist/commands/plugin/add.js +6 -3
  9. package/dist/commands/plugin/audit.js +4 -5
  10. package/dist/commands/plugin/pull.d.ts +3 -1
  11. package/dist/commands/plugin/pull.js +17 -9
  12. package/dist/commands/plugin/push.d.ts +5 -1
  13. package/dist/commands/plugin/push.js +90 -37
  14. package/dist/commands/plugin/status.d.ts +1 -0
  15. package/dist/commands/plugin/status.js +21 -2
  16. package/dist/commands/project/config.js +12 -8
  17. package/dist/commands/promote.d.ts +2 -0
  18. package/dist/commands/promote.js +69 -8
  19. package/dist/commands/pull.js +2 -2
  20. package/dist/commands/push.d.ts +1 -0
  21. package/dist/commands/push.js +37 -3
  22. package/dist/commands/template/diff.d.ts +10 -0
  23. package/dist/commands/template/diff.js +13 -0
  24. package/dist/commands/template/list.d.ts +13 -0
  25. package/dist/commands/template/list.js +23 -0
  26. package/dist/commands/template/push.d.ts +20 -0
  27. package/dist/commands/template/push.js +42 -0
  28. package/dist/commands/theme/pull.d.ts +3 -1
  29. package/dist/commands/theme/pull.js +10 -4
  30. package/dist/commands/theme/style/diff.d.ts +2 -0
  31. package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
  32. package/dist/commands/{theme-styles → theme/style}/pull.d.ts +1 -1
  33. package/dist/commands/{theme-styles → theme/style}/pull.js +3 -3
  34. package/dist/commands/{theme-styles → theme/style}/push.d.ts +1 -1
  35. package/dist/commands/{theme-styles → theme/style}/push.js +5 -5
  36. package/dist/commands/theme/style/rollback.d.ts +2 -0
  37. package/dist/commands/theme/style/rollback.js +6 -0
  38. package/dist/lib/api-client.js +3 -1
  39. package/dist/lib/app-build.d.ts +5 -0
  40. package/dist/lib/app-build.js +69 -0
  41. package/dist/lib/app-manifest.d.ts +4 -0
  42. package/dist/lib/app-manifest.js +8 -0
  43. package/dist/lib/base.js +11 -2
  44. package/dist/lib/bootstrap-full-install.js +45 -14
  45. package/dist/lib/dev-watch.d.ts +1 -1
  46. package/dist/lib/dev-watch.js +3 -1
  47. package/dist/lib/diff-command.d.ts +1 -0
  48. package/dist/lib/diff-command.js +6 -4
  49. package/dist/lib/resource-state.js +43 -1
  50. package/dist/lib/rollback-command.d.ts +1 -0
  51. package/dist/lib/rollback-command.js +8 -6
  52. package/dist/lib/rotate-app-password.js +2 -2
  53. package/dist/lib/validate-local.js +6 -0
  54. package/dist/lib/wp-client.d.ts +4 -1
  55. package/dist/lib/wp-client.js +25 -1
  56. package/dist/types/plugin.d.ts +8 -1
  57. package/dist/types/plugin.js +4 -1
  58. package/dist/types/project-config.generated.d.ts +19 -2
  59. package/dist/utils/composer.d.ts +14 -0
  60. package/dist/utils/composer.js +136 -0
  61. package/dist/utils/page-format.d.ts +8 -0
  62. package/dist/utils/page-format.js +34 -26
  63. package/dist/utils/plugins.d.ts +9 -4
  64. package/dist/utils/plugins.js +16 -7
  65. package/dist/utils/resource-dirs.d.ts +2 -0
  66. package/dist/utils/resource-dirs.js +4 -0
  67. package/dist/utils/seo-format.d.ts +2 -2
  68. package/dist/utils/template-format.d.ts +30 -0
  69. package/dist/utils/template-format.js +36 -0
  70. package/oclif.manifest.json +526 -324
  71. package/package.json +3 -2
  72. package/schemas/project-config.schema.json +25 -4
  73. package/dist/commands/theme-styles/diff.d.ts +0 -2
  74. package/dist/commands/theme-styles/rollback.d.ts +0 -2
  75. package/dist/commands/theme-styles/rollback.js +0 -5
@@ -7,8 +7,11 @@ export type AppEntry = {
7
7
  scripts: string[];
8
8
  styles: string[];
9
9
  };
10
+ export declare const CROSS_ORIGIN_POLICIES: readonly ["same-origin", "same-site"];
11
+ export type CrossOriginPolicy = (typeof CROSS_ORIGIN_POLICIES)[number];
10
12
  export type AppConfigFile = {
11
13
  assetsDir?: string;
14
+ crossOrigin?: CrossOriginPolicy;
12
15
  entry?: AppEntry;
13
16
  mountSelector?: string;
14
17
  name?: string;
@@ -16,6 +19,7 @@ export type AppConfigFile = {
16
19
  };
17
20
  export type AppManifest = {
18
21
  buildId: string;
22
+ crossOrigin?: CrossOriginPolicy;
19
23
  entry: AppEntry;
20
24
  files: AppFile[];
21
25
  mountSelector: string;
@@ -2,6 +2,9 @@ import { createHash } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { extname, join } from 'node:path';
4
4
  import { walkFiles } from './readdir-tolerant.js';
5
+ // Cross-Origin-Resource-Policy the server sends with the app's files. Mirrors the server's
6
+ // AppManifest::SUPPORTED_CROSS_ORIGIN; `cross-origin` is the browser default, i.e. the field unset.
7
+ export const CROSS_ORIGIN_POLICIES = ['same-origin', 'same-site'];
5
8
  export const APP_CONFIG_FILENAME = 'loopress.app.json';
6
9
  const DEFAULT_ASSETS_DIR = 'dist';
7
10
  // Static assets a bundler emits, mirrors the server's AppsDirectory::ALLOWED_EXTENSIONS.
@@ -89,6 +92,10 @@ export function parseAppConfig(raw, appDir) {
89
92
  if (config.routing !== undefined && config.routing !== 'hash') {
90
93
  throw new Error(`${join(appDir, APP_CONFIG_FILENAME)}: routing "${config.routing}" is not supported, only "hash" works without a server rewrite`);
91
94
  }
95
+ if (config.crossOrigin !== undefined && !CROSS_ORIGIN_POLICIES.includes(config.crossOrigin)) {
96
+ const supported = CROSS_ORIGIN_POLICIES.map((policy) => JSON.stringify(policy)).join(' or ');
97
+ throw new Error(`${join(appDir, APP_CONFIG_FILENAME)}: crossOrigin "${config.crossOrigin}" is not supported, use ${supported}`);
98
+ }
92
99
  return config;
93
100
  }
94
101
  // Assembles the full manifest for one app directory: reads loopress.app.json, hashes the
@@ -129,6 +136,7 @@ export async function loadAppManifest(appDir, dirName) {
129
136
  distDir,
130
137
  manifest: {
131
138
  buildId: computeBuildId(files),
139
+ ...(config.crossOrigin && { crossOrigin: config.crossOrigin }),
132
140
  entry,
133
141
  files,
134
142
  mountSelector: config.mountSelector ?? `#loopress-app-${name}`,
package/dist/lib/base.js CHANGED
@@ -35,11 +35,11 @@ export class LoopressCommand extends Command {
35
35
  }
36
36
  get wp() {
37
37
  if (!this.wpClient) {
38
- const { token, url } = this.siteConfig;
38
+ const { name, token, url } = this.siteConfig;
39
39
  if (!token) {
40
40
  this.error(`No credentials configured for ${url}. Run \`lps project config\` to add them.`);
41
41
  }
42
- this.wpClient = new WpClient(url, token);
42
+ this.wpClient = new WpClient(url, token, name);
43
43
  }
44
44
  return this.wpClient;
45
45
  }
@@ -65,12 +65,21 @@ export class LoopressCommand extends Command {
65
65
  // `dryRun` is excluded since rotating writes a new credential, a real side effect a
66
66
  // --dry-run run promises not to cause. `Rotate` overrides this to a no-op: its own `run()`
67
67
  // already rotates unconditionally, so the background check would only redo the same work.
68
+ // CI is excluded too: rotating revokes the old password, but a runner's config.json (a CI
69
+ // secret copy) is thrown away at the end of the job, so every later run would get a 401.
68
70
  async maybeAutoRotate() {
69
71
  if (this.dryRun)
70
72
  return;
71
73
  const { token } = this.siteConfig;
72
74
  if (!token || !isAppPasswordStale(this.siteConfig.addedAt))
73
75
  return;
76
+ // Set by `lps promote --dry-run` around its delegated pull: a preview must not revoke anything.
77
+ if (process.env.LOOPRESS_NO_AUTO_ROTATE)
78
+ return;
79
+ if (process.env.CI) {
80
+ this.warn(`The app password for "${this.siteConfig.name}" is older than 90 days. Auto-rotation is skipped in CI, run \`lps project rotate\` where this config.json lives, then update the CI secret.`);
81
+ return;
82
+ }
74
83
  try {
75
84
  const rotated = await rotateAppPassword({ ...this.siteConfig, token });
76
85
  this.siteConfig = rotated;
@@ -14,24 +14,55 @@ export async function bootstrapLoopressFull(wp, siteUrl, log) {
14
14
  const zipPath = await downloadLatestFullZip();
15
15
  log('Creating a temporary admin account to install it...');
16
16
  const admin = await createTempAdmin(wp);
17
+ // The temp account is a real administrator; it must never outlive this function, including
18
+ // when the process is interrupted mid-install (Ctrl-C, or a hung/killed headless browser).
19
+ // removeTempAdmin() memoises a single deletion so the normal path and either signal handler
20
+ // all await the same in-flight promise instead of racing or deleting twice.
21
+ let cleanupPromise;
22
+ const removeTempAdmin = async () => {
23
+ cleanupPromise ??= deleteTempAdmin(wp, admin);
24
+ return cleanupPromise;
25
+ };
26
+ const onSignal = (signal) => {
27
+ // Finish (or start) the deletion, surface a manual-removal hint if it fails since the normal
28
+ // error path below won't run, then re-raise the signal with our handler gone so Node's
29
+ // default termination runs with the right exit code.
30
+ removeTempAdmin()
31
+ .catch((error) => {
32
+ log(`Could not remove the temporary admin account "${admin.username}"; remove it manually in wp-admin. (${error.message})`);
33
+ })
34
+ .finally(() => {
35
+ process.removeListener('SIGINT', onSignal);
36
+ process.removeListener('SIGTERM', onSignal);
37
+ process.kill(process.pid, signal);
38
+ });
39
+ };
40
+ process.once('SIGINT', onSignal);
41
+ process.once('SIGTERM', onSignal);
17
42
  let installError;
18
- try {
19
- log('Installing and activating Loopress Full...');
20
- await runBrowserInstall(admin, siteUrl, zipPath);
21
- }
22
- catch (error) {
23
- installError = error;
24
- }
25
- if (!installError) {
26
- log('Loopress Full installed and activated.');
27
- }
28
- log('Removing the temporary admin account...');
29
43
  let cleanupError;
30
44
  try {
31
- await deleteTempAdmin(wp, admin);
45
+ try {
46
+ log('Installing and activating Loopress Full...');
47
+ await runBrowserInstall(admin, siteUrl, zipPath);
48
+ log('Loopress Full installed and activated.');
49
+ }
50
+ catch (error) {
51
+ installError = error;
52
+ }
53
+ log('Removing the temporary admin account...');
54
+ try {
55
+ await removeTempAdmin();
56
+ }
57
+ catch (error) {
58
+ cleanupError = error;
59
+ }
32
60
  }
33
- catch (error) {
34
- cleanupError = error;
61
+ finally {
62
+ // Only stop guarding once the deletion has settled: a signal during the removal above must
63
+ // still trigger (or await) cleanup rather than terminating the process with the account live.
64
+ process.removeListener('SIGINT', onSignal);
65
+ process.removeListener('SIGTERM', onSignal);
35
66
  }
36
67
  const manualFallback = `Install it manually: upload ${zipPath} at ${siteUrl}/wp-admin/plugin-install.php?tab=upload`;
37
68
  if (installError && cleanupError) {
@@ -1,5 +1,5 @@
1
1
  import { type LoopressLocalConfig } from '../utils/loopress-config.js';
2
- export type ResourceType = 'api' | 'hooks' | 'pages' | 'plugins' | 'snippets';
2
+ export type ResourceType = 'api' | 'hooks' | 'pages' | 'parts' | 'plugins' | 'snippets' | 'templates';
3
3
  export declare const RESOURCE_TYPES: ResourceType[];
4
4
  export type WatchTarget = {
5
5
  commandId: string;
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join, sep } from 'node:path';
3
- export const RESOURCE_TYPES = ['snippets', 'pages', 'api', 'hooks', 'plugins'];
3
+ export const RESOURCE_TYPES = ['snippets', 'templates', 'parts', 'pages', 'api', 'hooks', 'plugins'];
4
4
  // `only` narrows the default set, `skip` then removes from whatever that set is, same
5
5
  // precedence a combined `--only=a,b --skip=b` reads as: watch a, b, then drop b.
6
6
  export function resolveResourceTypes(only, skip = []) {
@@ -18,6 +18,8 @@ export function buildWatchTargets(types, localConfig, cwd) {
18
18
  hooks: { commandId: 'hook:push', path: join(rootDir, localConfig.hooksDir ?? 'hooks'), type: 'hooks' },
19
19
  pages: { commandId: 'page:push', path: join(rootDir, localConfig.pageDir ?? 'pages'), type: 'pages' },
20
20
  plugins: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugins' },
21
+ parts: { commandId: 'template:push', path: join(rootDir, localConfig.partDir ?? 'parts'), type: 'parts' },
22
+ templates: { commandId: 'template:push', path: join(rootDir, localConfig.templateDir ?? 'templates'), type: 'templates' },
21
23
  snippets: { commandId: 'snippet:push', path: join(rootDir, localConfig.snippetsDir ?? 'snippets'), type: 'snippets' },
22
24
  };
23
25
  return types
@@ -41,6 +41,7 @@ export declare abstract class DiffCommand extends LoopressCommand {
41
41
  private wpForEnvironment;
42
42
  }
43
43
  export declare function resourceDiffCommand(resource: string, options: {
44
+ command?: string;
44
45
  description: string;
45
46
  pathNoun: string;
46
47
  }): typeof DiffCommand;
@@ -143,12 +143,14 @@ export class DiffCommand extends LoopressCommand {
143
143
  if (!env.token) {
144
144
  this.error(`No credentials configured for "${envName}". Run \`lps project config\` to add them.`);
145
145
  }
146
- return new WpClient(env.url, env.token);
146
+ return new WpClient(env.url, env.token, env.name);
147
147
  }
148
148
  }
149
149
  // Builds the `lps <resource> diff` command class for one directory-backed resource. They differ
150
150
  // only in wording and which provider they target, so the whole body lives here.
151
+ // `command` is the CLI topic when it isn't the resource key (e.g. `theme style` for theme-styles).
151
152
  export function resourceDiffCommand(resource, options) {
153
+ const command = options.command ?? resource;
152
154
  class ResourceDiff extends DiffCommand {
153
155
  static args = {
154
156
  path: Args.string({ description: `Path to ${options.pathNoun} (overrides project config)` }),
@@ -156,9 +158,9 @@ export function resourceDiffCommand(resource, options) {
156
158
  static description = options.description;
157
159
  static enableJsonFlag = true;
158
160
  static examples = [
159
- `$ lps ${resource} diff`,
160
- `$ lps ${resource} diff --env staging`,
161
- `$ lps ${resource} diff --env staging --against production`,
161
+ `$ lps ${command} diff`,
162
+ `$ lps ${command} diff --env staging`,
163
+ `$ lps ${command} diff --env staging --against production`,
162
164
  ];
163
165
  static flags = { ...DiffCommand.againstFlag };
164
166
  async run() {
@@ -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
  }
@@ -4,6 +4,7 @@ import { configManager } from '../config/project-config.manager.js';
4
4
  import { parseLocalOption } from '../utils/option-format.js';
5
5
  import { readLocalPages } from '../utils/page-format.js';
6
6
  import { parseType } from '../utils/snippet-format.js';
7
+ import { readLocalTemplates } from '../utils/template-format.js';
7
8
  import { loadSnippets } from './load-snippets.js';
8
9
  import { readdirTolerant } from './readdir-tolerant.js';
9
10
  // Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
@@ -17,8 +18,10 @@ const DEFAULT_DIR = {
17
18
  menuDir: 'menus',
18
19
  optionsDir: 'options',
19
20
  pageDir: 'pages',
21
+ partDir: 'parts',
20
22
  seoDir: 'seo',
21
23
  snippetsDir: 'snippets',
24
+ templateDir: 'templates',
22
25
  themeStylesDir: 'theme',
23
26
  };
24
27
  // Inspects the local tracked files under `cwd` without contacting WordPress: JSON files parse
@@ -49,6 +52,9 @@ export async function validateLocal(cwd) {
49
52
  const pages = await readLocalPages(resolve('pageDir'));
50
53
  checked += pages.pages.length + pages.problems.length;
51
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);
52
58
  return { checked, problems, valid: problems.length === 0 };
53
59
  }
54
60
  // Recursive: both api/ and hooks/ files can live in a nested path-segment directory
@@ -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`,
@@ -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>;