@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.
- package/README.md +370 -203
- package/bin/dev.js +0 -0
- package/dist/commands/acf/pull.d.ts +9 -1
- package/dist/commands/acf/pull.js +2 -0
- package/dist/commands/acf/push.d.ts +9 -1
- package/dist/commands/acf/push.js +3 -1
- package/dist/commands/api/publish.js +1 -1
- package/dist/commands/app/push.js +1 -1
- package/dist/commands/composer/push.js +3 -3
- package/dist/commands/form/pull.d.ts +9 -1
- package/dist/commands/form/pull.js +3 -1
- package/dist/commands/form/push.d.ts +7 -1
- package/dist/commands/form/push.js +3 -1
- package/dist/commands/init.js +6 -38
- package/dist/commands/menu/pull.d.ts +7 -1
- package/dist/commands/menu/pull.js +2 -0
- package/dist/commands/menu/push.d.ts +7 -1
- package/dist/commands/menu/push.js +3 -1
- package/dist/commands/option/pull.js +4 -4
- package/dist/commands/option/push.js +2 -0
- package/dist/commands/page/push.js +3 -0
- package/dist/commands/plugin/pull.d.ts +4 -0
- package/dist/commands/plugin/pull.js +56 -10
- package/dist/commands/plugin/push.js +5 -4
- package/dist/commands/push.js +5 -4
- package/dist/commands/seo/pull.d.ts +8 -1
- package/dist/commands/seo/pull.js +2 -0
- package/dist/commands/seo/push.d.ts +7 -1
- package/dist/commands/seo/push.js +3 -1
- package/dist/commands/snippet/publish.js +1 -1
- package/dist/commands/snippet/pull.js +1 -1
- package/dist/commands/snippet/push.js +1 -1
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +4 -4
- package/dist/commands/theme/pull.d.ts +6 -10
- package/dist/commands/theme/pull.js +30 -32
- package/dist/commands/theme/push.d.ts +8 -9
- package/dist/commands/theme/push.js +54 -111
- package/dist/commands/theme/style/pull.d.ts +7 -1
- package/dist/commands/theme/style/pull.js +3 -1
- package/dist/commands/theme/style/push.d.ts +8 -1
- package/dist/commands/theme/style/push.js +4 -1
- package/dist/commands/{template → theme/template}/diff.d.ts +1 -1
- package/dist/commands/{template → theme/template}/diff.js +3 -3
- package/dist/commands/{template → theme/template}/list.d.ts +2 -2
- package/dist/commands/{template → theme/template}/list.js +5 -5
- package/dist/commands/{template → theme/template}/push.d.ts +1 -1
- package/dist/commands/{template → theme/template}/push.js +8 -6
- package/dist/commands/theme/version/pull.d.ts +23 -0
- package/dist/commands/theme/version/pull.js +43 -0
- package/dist/commands/theme/version/push.d.ts +22 -0
- package/dist/commands/theme/version/push.js +123 -0
- package/dist/hooks/finally.d.ts +2 -1
- package/dist/hooks/finally.js +27 -4
- package/dist/lib/app-manifest.js +2 -2
- package/dist/lib/base.d.ts +1 -0
- package/dist/lib/base.js +3 -0
- package/dist/lib/dev-watch.js +2 -2
- package/dist/lib/diff-state.js +1 -0
- package/dist/lib/github-release.js +0 -1
- package/dist/lib/local-callback-server.d.ts +12 -6
- package/dist/lib/local-callback-server.js +21 -22
- package/dist/lib/php-files-command.js +3 -1
- package/dist/lib/push-command.d.ts +5 -0
- package/dist/lib/push-command.js +30 -8
- package/dist/lib/resource-state.js +7 -6
- package/dist/lib/snapshot-store.js +2 -2
- package/dist/lib/wp-authorize-flow.d.ts +10 -17
- package/dist/lib/wp-authorize-flow.js +24 -28
- package/dist/lib/wp-site-diagnostic.js +37 -0
- package/dist/utils/composer.d.ts +3 -1
- package/dist/utils/composer.js +14 -6
- package/dist/utils/option-format.d.ts +3 -1
- package/dist/utils/option-format.js +14 -3
- package/dist/utils/page-format.d.ts +5 -1
- package/dist/utils/page-format.js +7 -3
- package/dist/utils/plugin-sync.js +1 -1
- package/dist/utils/resource-dirs.js +2 -2
- package/dist/utils/template-format.d.ts +2 -1
- package/dist/utils/template-format.js +10 -3
- package/oclif.manifest.json +937 -361
- package/package.json +22 -16
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { LoopressCommand } from '
|
|
2
|
-
import { type ChildTheme } from '
|
|
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 '
|
|
2
|
-
import { pluralize } from '
|
|
3
|
-
import { CHILD_THEME_ENDPOINT } from '
|
|
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,12 +1,13 @@
|
|
|
1
|
-
import { PushCommand } from '
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
+
}
|
package/dist/hooks/finally.d.ts
CHANGED
package/dist/hooks/finally.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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;
|
package/dist/lib/app-manifest.js
CHANGED
|
@@ -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)).
|
|
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
|
|
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);
|
package/dist/lib/base.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/dev-watch.js
CHANGED
|
@@ -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
|
package/dist/lib/diff-state.js
CHANGED
|
@@ -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
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `Origin
|
|
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
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* `Origin
|
|
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
|
-
|
|
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
|
-
|
|
47
|
+
function handleIncoming(req, res) {
|
|
54
48
|
try {
|
|
55
49
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
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
|
|
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}
|
|
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?: {
|