@loopress/cli 0.27.0 → 0.29.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 (124) hide show
  1. package/README.md +448 -189
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/acf/pull.d.ts +9 -1
  4. package/dist/commands/acf/pull.js +2 -0
  5. package/dist/commands/acf/push.d.ts +9 -1
  6. package/dist/commands/acf/push.js +3 -1
  7. package/dist/commands/api/publish.js +1 -1
  8. package/dist/commands/app/pull.js +17 -4
  9. package/dist/commands/app/push.d.ts +3 -0
  10. package/dist/commands/app/push.js +30 -6
  11. package/dist/commands/composer/push.js +3 -21
  12. package/dist/commands/diff.js +1 -1
  13. package/dist/commands/form/pull.d.ts +9 -1
  14. package/dist/commands/form/pull.js +3 -1
  15. package/dist/commands/form/push.d.ts +7 -1
  16. package/dist/commands/form/push.js +3 -1
  17. package/dist/commands/init.js +6 -36
  18. package/dist/commands/menu/pull.d.ts +7 -1
  19. package/dist/commands/menu/pull.js +2 -0
  20. package/dist/commands/menu/push.d.ts +7 -1
  21. package/dist/commands/menu/push.js +3 -1
  22. package/dist/commands/option/pull.js +4 -4
  23. package/dist/commands/option/push.js +2 -0
  24. package/dist/commands/page/push.js +3 -0
  25. package/dist/commands/plugin/add.js +6 -3
  26. package/dist/commands/plugin/audit.js +4 -5
  27. package/dist/commands/plugin/pull.d.ts +7 -1
  28. package/dist/commands/plugin/pull.js +68 -14
  29. package/dist/commands/plugin/push.d.ts +5 -1
  30. package/dist/commands/plugin/push.js +92 -38
  31. package/dist/commands/plugin/status.d.ts +1 -0
  32. package/dist/commands/plugin/status.js +21 -2
  33. package/dist/commands/project/config.js +12 -8
  34. package/dist/commands/promote.d.ts +2 -0
  35. package/dist/commands/promote.js +69 -8
  36. package/dist/commands/pull.js +2 -2
  37. package/dist/commands/push.d.ts +1 -0
  38. package/dist/commands/push.js +40 -5
  39. package/dist/commands/seo/pull.d.ts +8 -1
  40. package/dist/commands/seo/pull.js +2 -0
  41. package/dist/commands/seo/push.d.ts +7 -1
  42. package/dist/commands/seo/push.js +3 -1
  43. package/dist/commands/snippet/publish.js +1 -1
  44. package/dist/commands/snippet/pull.js +1 -1
  45. package/dist/commands/snippet/push.js +1 -1
  46. package/dist/commands/status.d.ts +1 -0
  47. package/dist/commands/status.js +4 -4
  48. package/dist/commands/theme/pull.d.ts +6 -8
  49. package/dist/commands/theme/pull.js +30 -26
  50. package/dist/commands/theme/push.d.ts +8 -9
  51. package/dist/commands/theme/push.js +54 -111
  52. package/dist/commands/theme/style/diff.d.ts +2 -0
  53. package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
  54. package/dist/commands/{theme-styles → theme/style}/pull.d.ts +8 -2
  55. package/dist/commands/{theme-styles → theme/style}/pull.js +6 -4
  56. package/dist/commands/{theme-styles → theme/style}/push.d.ts +9 -2
  57. package/dist/commands/{theme-styles → theme/style}/push.js +9 -6
  58. package/dist/commands/theme/style/rollback.d.ts +2 -0
  59. package/dist/commands/theme/style/rollback.js +6 -0
  60. package/dist/commands/theme/template/diff.d.ts +10 -0
  61. package/dist/commands/theme/template/diff.js +13 -0
  62. package/dist/commands/theme/template/list.d.ts +13 -0
  63. package/dist/commands/theme/template/list.js +23 -0
  64. package/dist/commands/theme/template/push.d.ts +20 -0
  65. package/dist/commands/theme/template/push.js +44 -0
  66. package/dist/commands/theme/version/pull.d.ts +23 -0
  67. package/dist/commands/theme/version/pull.js +43 -0
  68. package/dist/commands/theme/version/push.d.ts +22 -0
  69. package/dist/commands/theme/version/push.js +123 -0
  70. package/dist/hooks/finally.d.ts +2 -1
  71. package/dist/hooks/finally.js +27 -4
  72. package/dist/lib/api-client.js +3 -1
  73. package/dist/lib/app-build.d.ts +5 -0
  74. package/dist/lib/app-build.js +69 -0
  75. package/dist/lib/app-manifest.d.ts +4 -0
  76. package/dist/lib/app-manifest.js +10 -2
  77. package/dist/lib/base.d.ts +1 -0
  78. package/dist/lib/base.js +14 -2
  79. package/dist/lib/bootstrap-full-install.js +45 -14
  80. package/dist/lib/dev-watch.d.ts +1 -1
  81. package/dist/lib/dev-watch.js +3 -1
  82. package/dist/lib/diff-command.d.ts +1 -0
  83. package/dist/lib/diff-command.js +6 -4
  84. package/dist/lib/diff-state.js +1 -0
  85. package/dist/lib/github-release.js +0 -1
  86. package/dist/lib/local-callback-server.d.ts +12 -6
  87. package/dist/lib/local-callback-server.js +21 -22
  88. package/dist/lib/php-files-command.js +3 -1
  89. package/dist/lib/push-command.d.ts +5 -0
  90. package/dist/lib/push-command.js +30 -8
  91. package/dist/lib/resource-state.js +47 -4
  92. package/dist/lib/rollback-command.d.ts +1 -0
  93. package/dist/lib/rollback-command.js +8 -6
  94. package/dist/lib/rotate-app-password.js +2 -2
  95. package/dist/lib/snapshot-store.js +2 -2
  96. package/dist/lib/validate-local.js +6 -0
  97. package/dist/lib/wp-authorize-flow.d.ts +10 -17
  98. package/dist/lib/wp-authorize-flow.js +24 -28
  99. package/dist/lib/wp-client.d.ts +4 -1
  100. package/dist/lib/wp-client.js +25 -1
  101. package/dist/lib/wp-site-diagnostic.js +37 -0
  102. package/dist/types/plugin.d.ts +8 -1
  103. package/dist/types/plugin.js +4 -1
  104. package/dist/types/project-config.generated.d.ts +19 -2
  105. package/dist/utils/composer.d.ts +16 -0
  106. package/dist/utils/composer.js +144 -0
  107. package/dist/utils/option-format.d.ts +3 -1
  108. package/dist/utils/option-format.js +14 -3
  109. package/dist/utils/page-format.d.ts +12 -0
  110. package/dist/utils/page-format.js +40 -28
  111. package/dist/utils/plugin-sync.js +1 -1
  112. package/dist/utils/plugins.d.ts +9 -4
  113. package/dist/utils/plugins.js +16 -7
  114. package/dist/utils/resource-dirs.d.ts +2 -0
  115. package/dist/utils/resource-dirs.js +6 -2
  116. package/dist/utils/seo-format.d.ts +2 -2
  117. package/dist/utils/template-format.d.ts +31 -0
  118. package/dist/utils/template-format.js +43 -0
  119. package/oclif.manifest.json +841 -63
  120. package/package.json +24 -17
  121. package/schemas/project-config.schema.json +25 -4
  122. package/dist/commands/theme-styles/diff.d.ts +0 -2
  123. package/dist/commands/theme-styles/rollback.d.ts +0 -2
  124. package/dist/commands/theme-styles/rollback.js +0 -5
@@ -0,0 +1,123 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { confirmUninstall } from '../../../lib/interactive.js';
5
+ import { PushCommand } from '../../../lib/push-command.js';
6
+ import { isNotFoundError } from '../../../lib/wp-client.js';
7
+ import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS } from '../../../utils/plugin-sync.js';
8
+ import { lockedWpackagistSlugs } from '../../../utils/plugins.js';
9
+ import { diffThemes, parseInstalledThemes } from '../../../utils/themes.js';
10
+ const IN_SYNC = { installed: [], pinned: [], removed: [], status: 'in-sync' };
11
+ export default class Push extends PushCommand {
12
+ static description = 'Install themes on WordPress to match loopress.json (versions only, never switches the active theme)';
13
+ static enableJsonFlag = true;
14
+ static examples = ['$ lps theme version push', '$ lps theme version push --force'];
15
+ static flags = {
16
+ ...PushCommand.dryRunFlag,
17
+ ...PushCommand.yesFlag,
18
+ force: Flags.boolean({ default: false, description: 'Allow downgrades and take over themes installed outside Loopress' }),
19
+ };
20
+ async run() {
21
+ const { flags } = await this.parse(Push);
22
+ const { force } = flags;
23
+ if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
24
+ this.warn('This project has a composer.json, which is authoritative. Run `lps composer push` instead.');
25
+ return { ...IN_SYNC, status: 'composer-managed' };
26
+ }
27
+ const manifest = this.localConfig.themes ?? {};
28
+ if (Object.keys(manifest).length === 0) {
29
+ this.error('No themes found in loopress.json. Run `lps theme version pull` first.');
30
+ }
31
+ this.log(`Pushing themes to ${this.siteConfig.url}`);
32
+ const raw = await this.wp.get('wp/v2/themes');
33
+ const installed = parseInstalledThemes(raw);
34
+ const managed = lockedWpackagistSlugs(await this.fetchInstanceLock(), 'theme');
35
+ const diff = diffThemes(manifest, installed, managed);
36
+ this.guardForce(diff, force);
37
+ // Uninstalling the theme WordPress is currently rendering leaves the site with no active
38
+ // theme, which is a hard fatal. --force does not override this: switch the active theme
39
+ // on the site first, then the removal is safe. A --force takeover of a collision hits the
40
+ // same fatal: the server moves the existing folder out of the way before reinstalling it,
41
+ // so the active theme is briefly missing either way, an active collision is just as
42
+ // dangerous as an active removal.
43
+ const activeRemoval = [...diff.toRemove, ...diff.collisions.map((c) => c.slug)].find((slug) => installed.find((t) => t.slug === slug)?.active);
44
+ if (activeRemoval) {
45
+ this.error(`Refusing to uninstall "${activeRemoval}": it is the site's active theme. ` +
46
+ 'Switch the site to another theme, then re-run to remove it.');
47
+ }
48
+ // A "latest" pin never shows as drift, but its newest upstream release may have moved, so
49
+ // a push must still run `composer update` to pick it up.
50
+ const hasLatestPin = Object.values(manifest).includes('latest');
51
+ if (!hasLatestPin && isNoop(diff)) {
52
+ this.log('Everything is already in sync.');
53
+ return { ...IN_SYNC };
54
+ }
55
+ if (isNoop(diff))
56
+ this.log('Refreshing themes pinned to "latest" to their newest releases.');
57
+ for (const a of diff.toInstall)
58
+ this.log(` + ${a.slug} ${a.version}`);
59
+ for (const p of diff.toPin)
60
+ this.log(` ~ ${p.slug} ${p.from} to ${p.to}`);
61
+ for (const s of diff.toRemove)
62
+ this.log(` - ${s}`);
63
+ for (const c of diff.collisions)
64
+ this.log(` ! ${c.slug} (take over)`);
65
+ if (this.dryRun)
66
+ return result(diff, 'dry-run', diff.toRemove, true);
67
+ if (!(await confirmUninstall(diff.toRemove, this.yes)))
68
+ this.error('Aborted.');
69
+ const response = await this.sync(manifest, force);
70
+ if (response.output.trim())
71
+ this.log(response.output.trim());
72
+ this.log('Themes synced.');
73
+ await this.recordSuccess();
74
+ return result(diff, 'success', response.removed ?? diff.toRemove, force);
75
+ }
76
+ async fetchInstanceLock() {
77
+ try {
78
+ const { composerLock } = await this.wp.get('loopress/v1/composer/lock');
79
+ return composerLock;
80
+ }
81
+ catch (error) {
82
+ if (isNotFoundError(error))
83
+ return null;
84
+ throw error;
85
+ }
86
+ }
87
+ guardForce(diff, force) {
88
+ if (force)
89
+ return;
90
+ if (diff.collisions.length > 0) {
91
+ const list = diff.collisions.map((c) => `${c.slug} (${c.installedVersion})`).join(', ');
92
+ this.error(`${list} installed outside Loopress. Re-run with --force to take them over, or remove them from loopress.json.`);
93
+ }
94
+ const downgrades = diff.toPin.filter((p) => isDowngrade(p.from, p.to));
95
+ if (downgrades.length > 0) {
96
+ const list = downgrades.map((p) => `${p.slug} ${p.from} to ${p.to}`).join(', ');
97
+ this.error(`Refusing to downgrade: ${list}. Re-run with --force.`);
98
+ }
99
+ }
100
+ async sync(manifest, force) {
101
+ try {
102
+ return await this.wp.post('loopress/v1/composer/sync', { force, intent: { themes: manifest }, lock: null }, { timeoutMs: SYNC_TIMEOUT_MS });
103
+ }
104
+ catch (error) {
105
+ if (parseCollisions(error)) {
106
+ this.error('The site rejected the push: themes installed outside Loopress. Re-run with --force.');
107
+ }
108
+ throw error;
109
+ }
110
+ }
111
+ }
112
+ function isNoop(diff) {
113
+ return (diff.toInstall.length === 0 && diff.toPin.length === 0 && diff.toRemove.length === 0 && diff.collisions.length === 0);
114
+ }
115
+ function result(diff, status, removed, includeCollisions) {
116
+ const collisionSlugs = includeCollisions ? diff.collisions.map((c) => c.slug) : [];
117
+ return {
118
+ installed: [...diff.toInstall.map((a) => a.slug), ...collisionSlugs],
119
+ pinned: diff.toPin.map((p) => p.slug),
120
+ removed,
121
+ status,
122
+ };
123
+ }
@@ -1,3 +1,4 @@
1
- import type { Hook } from '@oclif/core';
1
+ import { type Hook } from '@oclif/core';
2
2
  declare const hook: Hook.Finally;
3
+ export declare function dropHttpCause(error: Error): void;
3
4
  export default hook;
@@ -1,3 +1,5 @@
1
+ import { settings } from '@oclif/core';
2
+ import { RequestError } from 'got';
1
3
  import { isTelemetryDisabled, redactArgv, resolveEnvironment, runtimeContext, scrubEvent, SENTRY_DSN, } from '../lib/sentry.js';
2
4
  // oclif has no `command_error` hook (checked @oclif/core@4.11.11's hooks.d.ts). `finally`
3
5
  // is the closest equivalent: it always runs at the end of the CLI lifecycle and carries
@@ -7,8 +9,15 @@ import { isTelemetryDisabled, redactArgv, resolveEnvironment, runtimeContext, sc
7
9
  // It's a heavy module (@opentelemetry deps, import-in-the-middle instrumentation), so loading
8
10
  // it eagerly on every command would tax the common case where commands succeed.
9
11
  const hook = async function (options) {
10
- if (!options.error || isTelemetryDisabled())
12
+ if (!options.error)
11
13
  return;
14
+ if (!isTelemetryDisabled())
15
+ await report(this, options);
16
+ // After the report, so Sentry still gets the whole chain. LPS_DEBUG=1 keeps it on screen too.
17
+ if (!settings.debug)
18
+ dropHttpCause(options.error);
19
+ };
20
+ async function report(context, options) {
12
21
  try {
13
22
  const Sentry = await import('@sentry/node');
14
23
  Sentry.init({
@@ -17,7 +26,7 @@ const hook = async function (options) {
17
26
  beforeSend: scrubEvent,
18
27
  dsn: SENTRY_DSN,
19
28
  environment: resolveEnvironment(),
20
- release: this.config.version,
29
+ release: context.config.version,
21
30
  // Node defaults `server_name` to os.hostname(), which is often the user's real name
22
31
  // (e.g. "jane-doe-macbook-pro"). sendDefaultPii covers IP addresses and similar, off by
23
32
  // default but set explicitly since this reports from users' own machines.
@@ -32,7 +41,21 @@ const hook = async function (options) {
32
41
  await Sentry.flush(2000);
33
42
  }
34
43
  catch (error) {
35
- this.debug('Failed to report error to Sentry: %O', error);
44
+ context.debug('Failed to report error to Sentry: %O', error);
36
45
  }
37
- };
46
+ }
47
+ // WpClient and ApiClient already fold what matters of a got error (status, the server's own
48
+ // reason) into their own message, and keep got's error as `cause` for the helpers that read
49
+ // its response. oclif prints every cause after the message, so without this a clear "Request
50
+ // failed (409) on ...: The active theme is not a block theme" ends with "Caused by: HTTPError:
51
+ // Request failed with status code 409 ... Code: ERR_NON_2XX_3XX_RESPONSE", which is what the
52
+ // user remembers. Other causes (a failed plugin install under its fallback advice) stay.
53
+ export function dropHttpCause(error) {
54
+ for (let current = error; current instanceof Error; current = current.cause) {
55
+ if (current.cause instanceof RequestError) {
56
+ delete current.cause;
57
+ return;
58
+ }
59
+ }
60
+ }
38
61
  export default hook;
@@ -45,7 +45,9 @@ function formatApiError(error, url, timeoutMs) {
45
45
  }
46
46
  const reason = nestErrorMessage(err.response?.body);
47
47
  if (status === 403) {
48
- return `Request rejected (${status}) on ${url}: ${reason ?? err.response?.body ?? err.message}`;
48
+ // An empty body (not just a missing one) falls back to got's message too, never a bare ": ".
49
+ const body = err.response?.body === '' ? undefined : err.response?.body;
50
+ return `Request rejected (${status}) on ${url}: ${reason ?? body ?? err.message}`;
49
51
  }
50
52
  // Any other API refusal (a 400 validation error, a 409 conflict) carries its explanation in
51
53
  // the body; got's own message ("Request failed with status code 400") never does.
@@ -0,0 +1,5 @@
1
+ declare const PACKAGE_MANAGERS: readonly ["bun", "npm", "pnpm", "yarn"];
2
+ type PackageManager = (typeof PACKAGE_MANAGERS)[number];
3
+ export declare function detectPackageManager(appDir: string, packageManagerField?: string): PackageManager;
4
+ export declare function buildApp(appDir: string, onStart?: (command: string) => void): Promise<string | undefined>;
5
+ export {};
@@ -0,0 +1,69 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ // The package managers `lps app push` may spawn. An allowlist, not the raw `packageManager`
6
+ // field: the name ends up as a process to run.
7
+ const PACKAGE_MANAGERS = ['bun', 'npm', 'pnpm', 'yarn'];
8
+ const LOCKFILES = [
9
+ ['pnpm-lock.yaml', 'pnpm'],
10
+ ['yarn.lock', 'yarn'],
11
+ ['bun.lock', 'bun'],
12
+ ['bun.lockb', 'bun'],
13
+ ['package-lock.json', 'npm'],
14
+ ];
15
+ // How many trailing lines of build output a failure message carries.
16
+ const OUTPUT_TAIL_LINES = 20;
17
+ // `packageManager` field first (corepack's own source of truth), then the nearest lockfile
18
+ // walking up to the repository root (an app inside a workspace shares the root lockfile),
19
+ // then npm.
20
+ export function detectPackageManager(appDir, packageManagerField) {
21
+ const fromField = packageManagerField?.split('@', 1)[0];
22
+ if (PACKAGE_MANAGERS.includes(fromField))
23
+ return fromField;
24
+ for (let dir = appDir;; dir = dirname(dir)) {
25
+ const lockfile = LOCKFILES.find(([file]) => existsSync(join(dir, file)));
26
+ if (lockfile)
27
+ return lockfile[1];
28
+ if (existsSync(join(dir, '.git')) || dirname(dir) === dir)
29
+ return 'npm';
30
+ }
31
+ }
32
+ // Runs the app's own `build` script, if its package.json declares one. Returns the command it
33
+ // ran, or undefined when there is nothing to build (no package.json, or no build script: the
34
+ // dist/ folder is then taken as is). Throws with the tail of the output when the build fails,
35
+ // so a broken build never ships the stale dist/ left over from the previous one.
36
+ export async function buildApp(appDir, onStart) {
37
+ const packageJsonPath = join(appDir, 'package.json');
38
+ if (!existsSync(packageJsonPath))
39
+ return undefined;
40
+ const pkg = JSON.parse(await readFile(packageJsonPath, 'utf8'));
41
+ if (!pkg.scripts?.build)
42
+ return undefined;
43
+ const pm = detectPackageManager(appDir, pkg.packageManager);
44
+ const command = `${pm} run build`;
45
+ onStart?.(command);
46
+ const { code, output } = await new Promise((resolve, reject) => {
47
+ // Windows resolves `npm` to `npm.cmd`, which only a shell finds. `pm` comes from the
48
+ // allowlist above, so the shell never sees user input.
49
+ const child = spawn(pm, ['run', 'build'], { cwd: appDir, shell: process.platform === 'win32' });
50
+ let out = '';
51
+ const collect = (chunk) => {
52
+ out += chunk;
53
+ };
54
+ child.stdout.setEncoding('utf8').on('data', collect);
55
+ child.stderr.setEncoding('utf8').on('data', collect);
56
+ child.on('error', reject);
57
+ child.on('close', (exitCode) => {
58
+ resolve({ code: exitCode, output: out });
59
+ });
60
+ });
61
+ if (code !== 0) {
62
+ const tail = output.trimEnd().split('\n').slice(-OUTPUT_TAIL_LINES).join('\n');
63
+ const hint = existsSync(join(appDir, 'node_modules'))
64
+ ? ''
65
+ : `\nnode_modules is missing: run \`${pm} install\` in ${appDir} first.`;
66
+ throw new Error(`\`${command}\` failed (exit code ${code}):\n${tail}${hint}`);
67
+ }
68
+ return command;
69
+ }
@@ -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.
@@ -19,7 +22,7 @@ export function isAllowedAsset(relPath) {
19
22
  // server would reject, so the push fails locally with a clear message instead of a 400
20
23
  // halfway through the upload.
21
24
  export async function buildFileList(distDir) {
22
- const relPaths = (await walkFiles(distDir)).sort((a, b) => a.localeCompare(b));
25
+ const relPaths = (await walkFiles(distDir)).toSorted((a, b) => a.localeCompare(b));
23
26
  const files = [];
24
27
  for (const path of relPaths) {
25
28
  if (!isAllowedAsset(path)) {
@@ -34,7 +37,7 @@ export async function buildFileList(distDir) {
34
37
  // Used as the enqueue `?ver` (cache busting) and as the build identity in `app list`.
35
38
  export function computeBuildId(files) {
36
39
  const digest = createHash('sha256');
37
- for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
40
+ for (const file of files.toSorted((a, b) => a.path.localeCompare(b.path))) {
38
41
  digest.update(`${file.path}:${file.sha256}\n`);
39
42
  }
40
43
  return digest.digest('hex').slice(0, 12);
@@ -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}`,
@@ -14,6 +14,7 @@ export declare abstract class LoopressCommand extends Command {
14
14
  };
15
15
  protected dryRun: boolean;
16
16
  protected localConfig: LoopressLocalConfig;
17
+ protected productionConfirmed: boolean;
17
18
  protected projectId: string;
18
19
  protected siteConfig: EnvironmentConfig;
19
20
  protected yes: boolean;
package/dist/lib/base.js CHANGED
@@ -26,6 +26,8 @@ export class LoopressCommand extends Command {
26
26
  };
27
27
  dryRun = false;
28
28
  localConfig = {};
29
+ // Set by PushCommand's hidden --production-confirmed, see there.
30
+ productionConfirmed = false;
29
31
  projectId;
30
32
  siteConfig;
31
33
  yes = false;
@@ -35,11 +37,11 @@ export class LoopressCommand extends Command {
35
37
  }
36
38
  get wp() {
37
39
  if (!this.wpClient) {
38
- const { token, url } = this.siteConfig;
40
+ const { name, token, url } = this.siteConfig;
39
41
  if (!token) {
40
42
  this.error(`No credentials configured for ${url}. Run \`lps project config\` to add them.`);
41
43
  }
42
- this.wpClient = new WpClient(url, token);
44
+ this.wpClient = new WpClient(url, token, name);
43
45
  }
44
46
  return this.wpClient;
45
47
  }
@@ -54,6 +56,7 @@ export class LoopressCommand extends Command {
54
56
  }));
55
57
  this.dryRun = Boolean(flags['dry-run']);
56
58
  this.yes = Boolean(flags.yes);
59
+ this.productionConfirmed = Boolean(flags['production-confirmed']);
57
60
  this.localConfig = await readLocalConfig();
58
61
  const resolved = this.resolveEnvironment(flags.env);
59
62
  this.siteConfig = resolved.env;
@@ -65,12 +68,21 @@ export class LoopressCommand extends Command {
65
68
  // `dryRun` is excluded since rotating writes a new credential, a real side effect a
66
69
  // --dry-run run promises not to cause. `Rotate` overrides this to a no-op: its own `run()`
67
70
  // already rotates unconditionally, so the background check would only redo the same work.
71
+ // CI is excluded too: rotating revokes the old password, but a runner's config.json (a CI
72
+ // secret copy) is thrown away at the end of the job, so every later run would get a 401.
68
73
  async maybeAutoRotate() {
69
74
  if (this.dryRun)
70
75
  return;
71
76
  const { token } = this.siteConfig;
72
77
  if (!token || !isAppPasswordStale(this.siteConfig.addedAt))
73
78
  return;
79
+ // Set by `lps promote --dry-run` around its delegated pull: a preview must not revoke anything.
80
+ if (process.env.LOOPRESS_NO_AUTO_ROTATE)
81
+ return;
82
+ if (process.env.CI) {
83
+ 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.`);
84
+ return;
85
+ }
74
86
  try {
75
87
  const rotated = await rotateAppPassword({ ...this.siteConfig, token });
76
88
  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: 'theme:template:push', path: join(rootDir, localConfig.partDir ?? 'parts'), type: 'parts' },
22
+ templates: { commandId: 'theme: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() {
@@ -29,6 +29,7 @@ function renderTextChange(id, left, right, labels) {
29
29
  function objectFieldChanges(left, right) {
30
30
  return microdiff(left, right, { cyclesFix: false }).map((change) => {
31
31
  const path = change.path.join('.');
32
+ // microdiff types leaf values as `any`.
32
33
  if (change.type === 'CREATE')
33
34
  return { kind: 'added', path, to: change.value };
34
35
  if (change.type === 'REMOVE')
@@ -5,7 +5,6 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { pipeline } from 'node:stream/promises';
7
7
  const REPO = 'loopress/loopress';
8
- const RELEASE_TAG_PATTERN = /^wordpress-plugin@/;
9
8
  const ASSET_NAME = 'loopress-full.zip';
10
9
  /**
11
10
  * Downloads `loopress-full.zip` from the most recent `wordpress-plugin@*` release of
@@ -1,5 +1,4 @@
1
1
  export type CallbackHelpers<T> = {
2
- body: Record<string, string>;
3
2
  rejectWithPage: (page: string, error: Error) => void;
4
3
  resolveWithPage: (page: string, value: T) => void;
5
4
  respondBadRequest: (message: string) => void;
@@ -9,13 +8,14 @@ export type CallbackHelpers<T> = {
9
8
  * local server; this factors out the server setup, timeout, and browser-opening boilerplate.
10
9
  *
11
10
  * The loopback server is unauthenticated, so any local process or web page open during the wait
12
- * could otherwise POST forged credentials to it (RFC 8252 section 8.9). Two guards close that:
11
+ * could otherwise send forged credentials to it (RFC 8252 section 8.9). Two guards close that:
13
12
  *
14
13
  * - a 32-byte `state` generated here, threaded into the authorize URL by `buildUrl`, and required
15
- * back (constant-time compare) on any request that carries credentials;
16
- * - an `Origin` allowlist: a browser sends `Origin` on the relay's cross-site form POST, so a
17
- * stray page's POST (carrying its own origin) is rejected. Top-level navigations send no
18
- * `Origin` and are allowed, which is why `state` is the primary control.
14
+ * back (constant-time compare) on any request with a query string, the only place credentials
15
+ * are read from;
16
+ * - an `Origin` allowlist: the legitimate callbacks are top-level redirects, which send no
17
+ * `Origin`, so a request from a page script (carrying its own origin) is rejected. That's
18
+ * defense in depth, `state` is the primary control.
19
19
  *
20
20
  * The first request that looks like a callback (valid or not) shuts the server down, so a
21
21
  * failed guess gets no second try within the window.
@@ -23,12 +23,18 @@ export type CallbackHelpers<T> = {
23
23
  export declare function waitForLocalCallback<T>(options: {
24
24
  allowedOrigins: string[];
25
25
  buildUrl: (callbackBaseUrl: string, state: string) => string;
26
+ /** Host advertised in the callback URL; the server itself always listens on 127.0.0.1. */
27
+ callbackHost?: string;
26
28
  handleRequest: (url: URL, helpers: CallbackHelpers<T>) => void;
27
29
  log: (message: string) => void;
28
30
  openingMessage: string;
29
31
  timeoutMessage: string;
30
32
  timeoutMs?: number;
31
33
  }): Promise<T>;
34
+ /**
35
+ * The page rewrites its own URL to `/` on load, so the callback query string (an Application
36
+ * Password, a console token) doesn't stay in the browser history or address bar.
37
+ */
32
38
  export declare function renderResultPage(options: {
33
39
  background: string;
34
40
  heading: string;