@loopress/cli 0.28.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 (82) hide show
  1. package/README.md +370 -203
  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/push.js +1 -1
  9. package/dist/commands/composer/push.js +3 -3
  10. package/dist/commands/form/pull.d.ts +9 -1
  11. package/dist/commands/form/pull.js +3 -1
  12. package/dist/commands/form/push.d.ts +7 -1
  13. package/dist/commands/form/push.js +3 -1
  14. package/dist/commands/init.js +6 -38
  15. package/dist/commands/menu/pull.d.ts +7 -1
  16. package/dist/commands/menu/pull.js +2 -0
  17. package/dist/commands/menu/push.d.ts +7 -1
  18. package/dist/commands/menu/push.js +3 -1
  19. package/dist/commands/option/pull.js +4 -4
  20. package/dist/commands/option/push.js +2 -0
  21. package/dist/commands/page/push.js +3 -0
  22. package/dist/commands/plugin/pull.d.ts +4 -0
  23. package/dist/commands/plugin/pull.js +56 -10
  24. package/dist/commands/plugin/push.js +5 -4
  25. package/dist/commands/push.js +5 -4
  26. package/dist/commands/seo/pull.d.ts +8 -1
  27. package/dist/commands/seo/pull.js +2 -0
  28. package/dist/commands/seo/push.d.ts +7 -1
  29. package/dist/commands/seo/push.js +3 -1
  30. package/dist/commands/snippet/publish.js +1 -1
  31. package/dist/commands/snippet/pull.js +1 -1
  32. package/dist/commands/snippet/push.js +1 -1
  33. package/dist/commands/status.d.ts +1 -0
  34. package/dist/commands/status.js +4 -4
  35. package/dist/commands/theme/pull.d.ts +6 -10
  36. package/dist/commands/theme/pull.js +30 -32
  37. package/dist/commands/theme/push.d.ts +8 -9
  38. package/dist/commands/theme/push.js +54 -111
  39. package/dist/commands/theme/style/pull.d.ts +7 -1
  40. package/dist/commands/theme/style/pull.js +3 -1
  41. package/dist/commands/theme/style/push.d.ts +8 -1
  42. package/dist/commands/theme/style/push.js +4 -1
  43. package/dist/commands/{template → theme/template}/diff.d.ts +1 -1
  44. package/dist/commands/{template → theme/template}/diff.js +3 -3
  45. package/dist/commands/{template → theme/template}/list.d.ts +2 -2
  46. package/dist/commands/{template → theme/template}/list.js +5 -5
  47. package/dist/commands/{template → theme/template}/push.d.ts +1 -1
  48. package/dist/commands/{template → theme/template}/push.js +8 -6
  49. package/dist/commands/theme/version/pull.d.ts +23 -0
  50. package/dist/commands/theme/version/pull.js +43 -0
  51. package/dist/commands/theme/version/push.d.ts +22 -0
  52. package/dist/commands/theme/version/push.js +123 -0
  53. package/dist/hooks/finally.d.ts +2 -1
  54. package/dist/hooks/finally.js +27 -4
  55. package/dist/lib/app-manifest.js +2 -2
  56. package/dist/lib/base.d.ts +1 -0
  57. package/dist/lib/base.js +3 -0
  58. package/dist/lib/dev-watch.js +2 -2
  59. package/dist/lib/diff-state.js +1 -0
  60. package/dist/lib/github-release.js +0 -1
  61. package/dist/lib/local-callback-server.d.ts +12 -6
  62. package/dist/lib/local-callback-server.js +21 -22
  63. package/dist/lib/php-files-command.js +3 -1
  64. package/dist/lib/push-command.d.ts +5 -0
  65. package/dist/lib/push-command.js +30 -8
  66. package/dist/lib/resource-state.js +7 -6
  67. package/dist/lib/snapshot-store.js +2 -2
  68. package/dist/lib/wp-authorize-flow.d.ts +10 -17
  69. package/dist/lib/wp-authorize-flow.js +24 -28
  70. package/dist/lib/wp-site-diagnostic.js +37 -0
  71. package/dist/utils/composer.d.ts +3 -1
  72. package/dist/utils/composer.js +14 -6
  73. package/dist/utils/option-format.d.ts +3 -1
  74. package/dist/utils/option-format.js +14 -3
  75. package/dist/utils/page-format.d.ts +5 -1
  76. package/dist/utils/page-format.js +7 -3
  77. package/dist/utils/plugin-sync.js +1 -1
  78. package/dist/utils/resource-dirs.js +2 -2
  79. package/dist/utils/template-format.d.ts +2 -1
  80. package/dist/utils/template-format.js +10 -3
  81. package/oclif.manifest.json +937 -361
  82. package/package.json +22 -16
@@ -1,5 +1,5 @@
1
- import { LoopressCommand } from '../../lib/base.js';
2
- import { type ChildTheme } from '../../utils/template-format.js';
1
+ import { LoopressCommand } from '../../../lib/base.js';
2
+ import { type ChildTheme } from '../../../utils/template-format.js';
3
3
  type ListResult = Omit<ChildTheme, 'parts' | 'templates'> & {
4
4
  parts: string[];
5
5
  templates: string[];
@@ -1,16 +1,16 @@
1
- import { LoopressCommand } from '../../lib/base.js';
2
- import { pluralize } from '../../utils/pluralize.js';
3
- import { CHILD_THEME_ENDPOINT } from '../../utils/template-format.js';
1
+ import { LoopressCommand } from '../../../lib/base.js';
2
+ import { pluralize } from '../../../utils/pluralize.js';
3
+ import { CHILD_THEME_ENDPOINT } from '../../../utils/template-format.js';
4
4
  export default class List extends LoopressCommand {
5
5
  static description = 'Show the Loopress child theme of the active block theme: its templates, parts, and those edited in the Site Editor';
6
6
  static enableJsonFlag = true;
7
- static examples = ['$ lps template list'];
7
+ static examples = ['$ lps theme template list'];
8
8
  async run() {
9
9
  const child = await this.wp.get(CHILD_THEME_ENDPOINT);
10
10
  // The markup is only needed by `template diff`, it would drown the JSON output here.
11
11
  const result = { ...child, parts: child.parts.map((part) => part.slug), templates: child.templates.map((template) => template.slug) };
12
12
  if (!child.exists) {
13
- this.log(`No Loopress child theme yet (${child.stylesheet}), run lps template push.`);
13
+ this.log(`No Loopress child theme yet (${child.stylesheet}), run lps theme template push.`);
14
14
  return result;
15
15
  }
16
16
  this.log(`${child.stylesheet} (child of ${child.parent}, ${child.active ? 'active' : 'not active'})`);
@@ -1,4 +1,4 @@
1
- import { PushCommand } from '../../lib/push-command.js';
1
+ import { PushCommand } from '../../../lib/push-command.js';
2
2
  type PushResult = {
3
3
  active?: boolean;
4
4
  customized?: string[];
@@ -1,12 +1,13 @@
1
- import { PushCommand } from '../../lib/push-command.js';
2
- import { formatPageProblems } from '../../utils/page-format.js';
3
- import { pluralize } from '../../utils/pluralize.js';
4
- import { resolveResourceDir } from '../../utils/resource-dirs.js';
5
- import { CHILD_THEME_ENDPOINT, readLocalTemplates } from '../../utils/template-format.js';
1
+ import { PushCommand } from '../../../lib/push-command.js';
2
+ import { getResourceStateProvider } from '../../../lib/resource-state.js';
3
+ import { formatPageProblems } from '../../../utils/page-format.js';
4
+ import { pluralize } from '../../../utils/pluralize.js';
5
+ import { resolveResourceDir } from '../../../utils/resource-dirs.js';
6
+ import { CHILD_THEME_ENDPOINT, readLocalTemplates } from '../../../utils/template-format.js';
6
7
  export default class Push extends PushCommand {
7
8
  static description = 'Push block templates (templates/<slug>.html) and template parts (parts/<slug>.html) as the files of a child theme of the active block theme, <parent>-loopress. The child mirrors the project: files removed locally are removed from it. It is never activated for you.';
8
9
  static enableJsonFlag = true;
9
- static examples = ['$ lps template push', '$ lps template push --dry-run'];
10
+ static examples = ['$ lps theme template push', '$ lps theme template push --dry-run'];
10
11
  static flags = {
11
12
  ...PushCommand.dryRunFlag,
12
13
  ...PushCommand.yesFlag,
@@ -24,6 +25,7 @@ export default class Push extends PushCommand {
24
25
  const names = { parts: parts.map((part) => part.slug), templates: templates.map((template) => template.slug) };
25
26
  this.log(`Found ${pluralize(templates.length, 'template')} and ${pluralize(parts.length, 'part')}`);
26
27
  if (this.dryRun) {
28
+ await this.previewRemote(getResourceStateProvider('template'), templatesPath);
27
29
  this.log('[dry-run] Would rewrite the child theme with them.');
28
30
  return { ...names, status: 'dry-run' };
29
31
  }
@@ -0,0 +1,23 @@
1
+ import { LoopressCommand } from '../../../lib/base.js';
2
+ import { type ComposerPullResult } from '../../../utils/composer.js';
3
+ type PullResult = {
4
+ added: string[];
5
+ merged: Record<string, string>;
6
+ skipped?: ComposerPullResult['skipped'];
7
+ status: 'dry-run' | 'success';
8
+ updated: Array<{
9
+ from: string;
10
+ slug: string;
11
+ to: string;
12
+ }>;
13
+ };
14
+ export default class Pull extends LoopressCommand {
15
+ static description: string;
16
+ static enableJsonFlag: boolean;
17
+ static examples: string[];
18
+ static flags: {
19
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
+ };
21
+ run(): Promise<PullResult>;
22
+ }
23
+ export {};
@@ -0,0 +1,43 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { LoopressCommand } from '../../../lib/base.js';
4
+ import { pullIntoComposerJson } from '../../../utils/composer.js';
5
+ import { writeLocalConfig } from '../../../utils/loopress-config.js';
6
+ import { mergePluginManifest } from '../../../utils/plugins.js';
7
+ import { parseInstalledThemes } from '../../../utils/themes.js';
8
+ export default class Pull extends LoopressCommand {
9
+ static description = 'Pull installed themes from WordPress into loopress.json, pinned to their live versions';
10
+ static enableJsonFlag = true;
11
+ static examples = ['$ lps theme version pull'];
12
+ static flags = {
13
+ ...LoopressCommand.dryRunFlag,
14
+ };
15
+ async run() {
16
+ const { url } = this.siteConfig;
17
+ this.log(`Pulling themes from ${url}`);
18
+ const raw = await this.wp.get('wp/v2/themes');
19
+ const installed = parseInstalledThemes(raw);
20
+ const incoming = Object.fromEntries(installed.map((t) => [t.slug, t.version]));
21
+ // A composer.json is authoritative over loopress.json: pin the live versions there instead.
22
+ const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
23
+ if (existsSync(composerJsonPath)) {
24
+ const result = await pullIntoComposerJson(composerJsonPath, 'theme', incoming, {
25
+ dryRun: this.dryRun,
26
+ log: this.log.bind(this),
27
+ });
28
+ return { ...result, status: this.dryRun ? 'dry-run' : 'success' };
29
+ }
30
+ const { added, merged, updated } = mergePluginManifest(this.localConfig.themes ?? {}, incoming);
31
+ if (this.dryRun) {
32
+ this.log(`[dry-run] Would write ${Object.keys(merged).length} themes to loopress.json`);
33
+ return { added, merged, status: 'dry-run', updated };
34
+ }
35
+ await writeLocalConfig({ ...this.localConfig, themes: merged });
36
+ this.log(`Wrote ${Object.keys(merged).length} themes to loopress.json`);
37
+ if (added.length > 0)
38
+ this.log(` + Added: ${added.join(', ')}`);
39
+ for (const u of updated)
40
+ this.log(` ~ Updated: ${u.slug} ${u.from} → ${u.to}`);
41
+ return { added, merged, status: 'success', updated };
42
+ }
43
+ }
@@ -0,0 +1,22 @@
1
+ import { PushCommand } from '../../../lib/push-command.js';
2
+ type PushResult = {
3
+ installed: string[];
4
+ pinned: string[];
5
+ removed: string[];
6
+ status: 'composer-managed' | 'dry-run' | 'in-sync' | 'success';
7
+ };
8
+ export default class Push extends PushCommand {
9
+ static description: string;
10
+ static enableJsonFlag: boolean;
11
+ static examples: string[];
12
+ static flags: {
13
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ };
17
+ run(): Promise<PushResult>;
18
+ private fetchInstanceLock;
19
+ private guardForce;
20
+ private sync;
21
+ }
22
+ export {};
@@ -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;
@@ -22,7 +22,7 @@ export function isAllowedAsset(relPath) {
22
22
  // server would reject, so the push fails locally with a clear message instead of a 400
23
23
  // halfway through the upload.
24
24
  export async function buildFileList(distDir) {
25
- const relPaths = (await walkFiles(distDir)).sort((a, b) => a.localeCompare(b));
25
+ const relPaths = (await walkFiles(distDir)).toSorted((a, b) => a.localeCompare(b));
26
26
  const files = [];
27
27
  for (const path of relPaths) {
28
28
  if (!isAllowedAsset(path)) {
@@ -37,7 +37,7 @@ export async function buildFileList(distDir) {
37
37
  // Used as the enqueue `?ver` (cache busting) and as the build identity in `app list`.
38
38
  export function computeBuildId(files) {
39
39
  const digest = createHash('sha256');
40
- 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))) {
41
41
  digest.update(`${file.path}:${file.sha256}\n`);
42
42
  }
43
43
  return digest.digest('hex').slice(0, 12);
@@ -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;
@@ -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;
@@ -18,8 +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
+ 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' },
23
23
  snippets: { commandId: 'snippet:push', path: join(rootDir, localConfig.snippetsDir ?? 'snippets'), type: 'snippets' },
24
24
  };
25
25
  return types
@@ -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;
@@ -2,15 +2,6 @@ import { Buffer } from 'node:buffer';
2
2
  import { randomBytes, timingSafeEqual } from 'node:crypto';
3
3
  import { createServer } from 'node:http';
4
4
  import { openBrowser } from './open-browser.js';
5
- async function readBody(req) {
6
- const chunks = [];
7
- for await (const chunk of req)
8
- chunks.push(chunk);
9
- return Buffer.concat(chunks).toString('utf8');
10
- }
11
- function parseFormData(body) {
12
- return Object.fromEntries(new URLSearchParams(body));
13
- }
14
5
  /** Constant-time compare that also tolerates a length mismatch without throwing. */
15
6
  function safeEqual(a, b) {
16
7
  const bufferA = Buffer.from(a);
@@ -22,13 +13,14 @@ function safeEqual(a, b) {
22
13
  * local server; this factors out the server setup, timeout, and browser-opening boilerplate.
23
14
  *
24
15
  * The loopback server is unauthenticated, so any local process or web page open during the wait
25
- * could otherwise POST forged credentials to it (RFC 8252 section 8.9). Two guards close that:
16
+ * could otherwise send forged credentials to it (RFC 8252 section 8.9). Two guards close that:
26
17
  *
27
18
  * - a 32-byte `state` generated here, threaded into the authorize URL by `buildUrl`, and required
28
- * back (constant-time compare) on any request that carries credentials;
29
- * - an `Origin` allowlist: a browser sends `Origin` on the relay's cross-site form POST, so a
30
- * stray page's POST (carrying its own origin) is rejected. Top-level navigations send no
31
- * `Origin` and are allowed, which is why `state` is the primary control.
19
+ * back (constant-time compare) on any request with a query string, the only place credentials
20
+ * are read from;
21
+ * - an `Origin` allowlist: the legitimate callbacks are top-level redirects, which send no
22
+ * `Origin`, so a request from a page script (carrying its own origin) is rejected. That's
23
+ * defense in depth, `state` is the primary control.
32
24
  *
33
25
  * The first request that looks like a callback (valid or not) shuts the server down, so a
34
26
  * failed guess gets no second try within the window.
@@ -38,23 +30,26 @@ export async function waitForLocalCallback(options) {
38
30
  const state = randomBytes(32).toString('hex');
39
31
  return new Promise((resolve, reject) => {
40
32
  function finish(res, page) {
41
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
33
+ // `Connection: close` (here and below): server.close() only refuses new connections, a
34
+ // kept-alive socket would otherwise still reach this handler after the shutdown.
35
+ res.writeHead(200, { Connection: 'close', 'Content-Type': 'text/html; charset=utf-8' });
42
36
  res.end(page);
43
37
  clearTimeout(timer);
44
38
  server.close();
45
39
  }
46
40
  function rejectRequest(res, message) {
47
- res.writeHead(403, { 'Content-Type': 'text/plain' });
41
+ res.writeHead(403, { Connection: 'close', 'Content-Type': 'text/plain' });
48
42
  res.end(message);
49
43
  clearTimeout(timer);
50
44
  server.close();
51
45
  reject(new Error(message));
52
46
  }
53
- async function handleIncoming(req, res) {
47
+ function handleIncoming(req, res) {
54
48
  try {
55
49
  const url = new URL(req.url ?? '/', 'http://localhost');
56
- const body = req.method === 'POST' ? parseFormData(await readBody(req)) : {};
57
- const looksLikeCallback = url.searchParams.has('state') || Object.keys(body).length > 0;
50
+ // Any query string counts, not just one carrying `state`: credentials are read from the
51
+ // query, so a request with `password=` but no `state` must not slip past the check.
52
+ const looksLikeCallback = url.search !== '';
58
53
  if (looksLikeCallback) {
59
54
  const { origin } = req.headers;
60
55
  if (origin !== undefined && !options.allowedOrigins.includes(origin)) {
@@ -79,7 +74,6 @@ export async function waitForLocalCallback(options) {
79
74
  res.writeHead(400, { 'Content-Type': 'text/plain' });
80
75
  res.end(message);
81
76
  },
82
- body,
83
77
  });
84
78
  }
85
79
  catch (error) {
@@ -89,7 +83,7 @@ export async function waitForLocalCallback(options) {
89
83
  reject(error instanceof Error ? error : new Error(String(error)));
90
84
  }
91
85
  }
92
- const server = createServer((req, res) => { void handleIncoming(req, res); });
86
+ const server = createServer(handleIncoming);
93
87
  server.on('error', (err) => {
94
88
  clearTimeout(timer);
95
89
  reject(err);
@@ -100,13 +94,17 @@ export async function waitForLocalCallback(options) {
100
94
  }, timeoutMs);
101
95
  server.listen(0, '127.0.0.1', () => {
102
96
  const { port } = server.address();
103
- const targetUrl = options.buildUrl(`http://localhost:${port}`, state);
97
+ const targetUrl = options.buildUrl(`http://${options.callbackHost ?? 'localhost'}:${port}`, state);
104
98
  options.log(options.openingMessage);
105
99
  options.log(`\nIf it doesn't open automatically, visit:\n${targetUrl}\n`);
106
100
  openBrowser(targetUrl);
107
101
  });
108
102
  });
109
103
  }
104
+ /**
105
+ * The page rewrites its own URL to `/` on load, so the callback query string (an Application
106
+ * Password, a console token) doesn't stay in the browser history or address bar.
107
+ */
110
108
  export function renderResultPage(options) {
111
109
  return `<!DOCTYPE html>
112
110
  <html lang="en">
@@ -144,6 +142,7 @@ export function renderResultPage(options) {
144
142
  <h1>${options.heading}</h1>
145
143
  <p>You can close this tab and return to your terminal.</p>
146
144
  </div>
145
+ <script>history.replaceState(null, '', '/')</script>
147
146
  </body>
148
147
  </html>`;
149
148
  }
@@ -274,6 +274,8 @@ export function resourceListCommand(spec) {
274
274
  }
275
275
  export function resourceRmCommand(spec) {
276
276
  class ResourceRm extends LoopressCommand {
277
+ // `remove` matches `lps app remove` and `lps option remove`, `rm` stays for existing scripts.
278
+ static aliases = [`${spec.cliName}:remove`];
277
279
  static args = {
278
280
  filename: Args.string({
279
281
  description: `The ${spec.noun} to remove, its slug without the .php extension (e.g. "hello" or "invoice-pdf/[order_id]")`,
@@ -282,7 +284,7 @@ export function resourceRmCommand(spec) {
282
284
  };
283
285
  static description = spec.rmDescription;
284
286
  static enableJsonFlag = true;
285
- static examples = [`$ lps ${spec.cliName} rm hello`, `$ lps ${spec.cliName} rm hello --yes`];
287
+ static examples = [`$ lps ${spec.cliName} rm hello`, `$ lps ${spec.cliName} remove hello --yes`];
286
288
  static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
287
289
  async run() {
288
290
  const { args } = await this.parse(ResourceRm);
@@ -2,12 +2,17 @@ import { LoopressCommand } from './base.js';
2
2
  import { type ResourceState } from './diff-state.js';
3
3
  import { type ResourceStateProvider } from './resource-state.js';
4
4
  export declare abstract class PushCommand extends LoopressCommand {
5
+ static baseFlags: {
6
+ 'production-confirmed': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ };
5
9
  protected failedCount: number;
6
10
  private refusedByGuard;
7
11
  protected captureBeforePushState(provider: ResourceStateProvider, dir: string): Promise<ResourceState | undefined>;
8
12
  catch(err: Error): Promise<void>;
9
13
  protected guardProductionPush(): Promise<void>;
10
14
  init(): Promise<void>;
15
+ protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<void>;
11
16
  protected recordDeployment(status: 'failure' | 'success'): Promise<void>;
12
17
  protected recordSuccess(): Promise<void>;
13
18
  protected reportTaskFailure(message: string, error: unknown, task?: {