@loopress/cli 0.22.0 → 0.24.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 +794 -85
- package/dist/commands/acf/diff.d.ts +2 -0
- package/dist/commands/acf/diff.js +5 -0
- package/dist/commands/api/diff.d.ts +2 -0
- package/dist/commands/api/diff.js +5 -0
- package/dist/commands/api/list.d.ts +2 -12
- package/dist/commands/api/list.js +2 -20
- package/dist/commands/api/pull.d.ts +2 -20
- package/dist/commands/api/pull.js +2 -53
- package/dist/commands/api/push.d.ts +2 -22
- package/dist/commands/api/push.js +2 -116
- package/dist/commands/app/list.d.ts +17 -0
- package/dist/commands/app/list.js +35 -0
- package/dist/commands/{page → app}/pull.d.ts +7 -5
- package/dist/commands/app/pull.js +94 -0
- package/dist/commands/app/push.d.ts +28 -0
- package/dist/commands/app/push.js +130 -0
- package/dist/commands/app/remove.d.ts +20 -0
- package/dist/commands/app/remove.js +42 -0
- package/dist/commands/composer/pull.js +6 -16
- package/dist/commands/composer/push.d.ts +1 -0
- package/dist/commands/composer/push.js +43 -19
- package/dist/commands/dev.js +1 -1
- package/dist/commands/diff.d.ts +13 -0
- package/dist/commands/diff.js +42 -0
- package/dist/commands/form/diff.d.ts +2 -0
- package/dist/commands/form/diff.js +5 -0
- package/dist/commands/hook/diff.d.ts +2 -0
- package/dist/commands/hook/diff.js +5 -0
- package/dist/commands/hook/list.d.ts +2 -0
- package/dist/commands/hook/list.js +2 -0
- package/dist/commands/hook/pull.d.ts +2 -0
- package/dist/commands/hook/pull.js +2 -0
- package/dist/commands/hook/push.d.ts +2 -0
- package/dist/commands/hook/push.js +2 -0
- package/dist/commands/init.js +2 -1
- package/dist/commands/option/add.d.ts +15 -0
- package/dist/commands/option/add.js +47 -0
- package/dist/commands/option/diff.d.ts +2 -0
- package/dist/commands/option/diff.js +5 -0
- package/dist/commands/{page → option}/list.d.ts +5 -1
- package/dist/commands/option/list.js +151 -0
- package/dist/commands/option/pull.d.ts +16 -0
- package/dist/commands/option/pull.js +75 -0
- package/dist/commands/{page → option}/push.d.ts +3 -8
- package/dist/commands/option/push.js +66 -0
- package/dist/commands/option/remove.d.ts +22 -0
- package/dist/commands/option/remove.js +69 -0
- package/dist/commands/plugin/add.d.ts +1 -0
- package/dist/commands/plugin/add.js +18 -8
- package/dist/commands/plugin/audit.d.ts +30 -0
- package/dist/commands/plugin/audit.js +138 -0
- package/dist/commands/plugin/pull.d.ts +1 -2
- package/dist/commands/plugin/pull.js +11 -11
- package/dist/commands/plugin/push.d.ts +13 -8
- package/dist/commands/plugin/push.js +161 -67
- package/dist/commands/plugin/status.d.ts +22 -0
- package/dist/commands/plugin/status.js +65 -0
- package/dist/commands/pull.js +3 -2
- package/dist/commands/push.js +4 -3
- package/dist/commands/seo/diff.d.ts +2 -0
- package/dist/commands/seo/diff.js +5 -0
- package/dist/commands/snippet/diff.d.ts +2 -0
- package/dist/commands/snippet/diff.js +5 -0
- package/dist/commands/theme/add.d.ts +13 -0
- package/dist/commands/theme/add.js +35 -0
- package/dist/commands/theme/pull.d.ts +21 -0
- package/dist/commands/theme/pull.js +37 -0
- package/dist/commands/theme/push.d.ts +22 -0
- package/dist/commands/theme/push.js +123 -0
- package/dist/commands/theme/status.d.ts +20 -0
- package/dist/commands/theme/status.js +51 -0
- package/dist/lib/app-manifest.d.ts +36 -0
- package/dist/lib/app-manifest.js +162 -0
- package/dist/lib/base.d.ts +3 -1
- package/dist/lib/base.js +14 -19
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +1 -2
- package/dist/lib/diff-command.d.ts +47 -0
- package/dist/lib/diff-command.js +180 -0
- package/dist/lib/diff-state.d.ts +22 -0
- package/dist/lib/diff-state.js +88 -0
- package/dist/lib/find-orphaned-files.js +1 -1
- package/dist/lib/interactive.d.ts +1 -0
- package/dist/lib/interactive.js +10 -0
- package/dist/lib/php-files-command.d.ts +54 -0
- package/dist/lib/php-files-command.js +216 -0
- package/dist/lib/put-or-create.js +1 -1
- package/dist/lib/readdir-tolerant.d.ts +1 -0
- package/dist/lib/readdir-tolerant.js +3 -1
- package/dist/lib/resource-state.d.ts +14 -0
- package/dist/lib/resource-state.js +361 -0
- package/dist/lib/validate-local.js +15 -7
- package/dist/lib/wp-client.d.ts +5 -0
- package/dist/lib/wp-client.js +56 -0
- package/dist/types/plugin.d.ts +1 -0
- package/dist/types/project-config.generated.d.ts +19 -5
- package/dist/utils/option-format.d.ts +31 -0
- package/dist/utils/option-format.js +51 -0
- package/dist/utils/plugin-sync.d.ts +26 -0
- package/dist/utils/plugin-sync.js +31 -0
- package/dist/utils/plugins.d.ts +15 -2
- package/dist/utils/plugins.js +57 -12
- package/dist/utils/resource-dirs.d.ts +13 -0
- package/dist/utils/resource-dirs.js +34 -0
- package/dist/utils/themes.d.ts +30 -0
- package/dist/utils/themes.js +29 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/version.js +21 -0
- package/oclif.manifest.json +1897 -244
- package/package.json +26 -19
- package/schemas/project-config.schema.json +32 -9
- package/dist/commands/page/list.js +0 -19
- package/dist/commands/page/pull.js +0 -57
- package/dist/commands/page/push.js +0 -132
- package/dist/utils/page-format.d.ts +0 -7
- package/dist/utils/page-format.js +0 -78
|
@@ -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 push', '$ lps theme 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 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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
type StatusResult = {
|
|
3
|
+
drift: boolean;
|
|
4
|
+
missing: string[];
|
|
5
|
+
status: 'composer-managed' | 'drift' | 'in-sync';
|
|
6
|
+
wrongVersion: Array<{
|
|
7
|
+
live: string;
|
|
8
|
+
pinned: string;
|
|
9
|
+
slug: string;
|
|
10
|
+
}>;
|
|
11
|
+
};
|
|
12
|
+
export default class Status extends LoopressCommand {
|
|
13
|
+
static description: string;
|
|
14
|
+
static enableJsonFlag: boolean;
|
|
15
|
+
static examples: string[];
|
|
16
|
+
static flags: {};
|
|
17
|
+
run(): Promise<StatusResult>;
|
|
18
|
+
private fetchInstanceLock;
|
|
19
|
+
}
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
4
|
+
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
5
|
+
import { lockedWpackagistSlugs } from '../../utils/plugins.js';
|
|
6
|
+
import { diffThemes, parseInstalledThemes } from '../../utils/themes.js';
|
|
7
|
+
export default class Status extends LoopressCommand {
|
|
8
|
+
static description = 'Compare the themes on WordPress against loopress.json and report version drift';
|
|
9
|
+
static enableJsonFlag = true;
|
|
10
|
+
static examples = ['$ lps theme status'];
|
|
11
|
+
static flags = {};
|
|
12
|
+
async run() {
|
|
13
|
+
const { url } = this.siteConfig;
|
|
14
|
+
if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
|
|
15
|
+
this.warn('This project has a composer.json, which is authoritative. Run `lps composer` commands instead.');
|
|
16
|
+
return { drift: false, missing: [], status: 'composer-managed', wrongVersion: [] };
|
|
17
|
+
}
|
|
18
|
+
const manifest = this.localConfig.themes ?? {};
|
|
19
|
+
const raw = await this.wp.get('wp/v2/themes');
|
|
20
|
+
const installed = parseInstalledThemes(raw);
|
|
21
|
+
const managed = lockedWpackagistSlugs(await this.fetchInstanceLock(), 'theme');
|
|
22
|
+
const diff = diffThemes(manifest, installed, managed);
|
|
23
|
+
const missing = [...diff.toInstall.map((a) => a.slug), ...diff.collisions.map((c) => c.slug)];
|
|
24
|
+
const wrongVersion = diff.toPin.map((p) => ({ live: p.from, pinned: p.to, slug: p.slug }));
|
|
25
|
+
const drift = missing.length > 0 || wrongVersion.length > 0 || diff.toRemove.length > 0;
|
|
26
|
+
if (!drift) {
|
|
27
|
+
this.log(`In sync with loopress.json (${url})`);
|
|
28
|
+
return { drift: false, missing: [], status: 'in-sync', wrongVersion: [] };
|
|
29
|
+
}
|
|
30
|
+
if (diff.toInstall.length > 0)
|
|
31
|
+
this.log(`Not installed: ${diff.toInstall.map((a) => a.slug).join(', ')}`);
|
|
32
|
+
if (diff.collisions.length > 0)
|
|
33
|
+
this.log(`Installed outside Loopress: ${diff.collisions.map((c) => c.slug).join(', ')}`);
|
|
34
|
+
for (const w of wrongVersion)
|
|
35
|
+
this.log(`Version drift: ${w.slug} is ${w.live}, loopress.json pins ${w.pinned}`);
|
|
36
|
+
if (diff.toRemove.length > 0)
|
|
37
|
+
this.log(`Managed by Loopress but dropped from loopress.json: ${diff.toRemove.join(', ')}`);
|
|
38
|
+
this.exit(1);
|
|
39
|
+
}
|
|
40
|
+
async fetchInstanceLock() {
|
|
41
|
+
try {
|
|
42
|
+
const { composerLock } = await this.wp.get('loopress/v1/composer/lock');
|
|
43
|
+
return composerLock;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (isNotFoundError(error))
|
|
47
|
+
return null;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type AppFile = {
|
|
2
|
+
path: string;
|
|
3
|
+
sha256: string;
|
|
4
|
+
size: number;
|
|
5
|
+
};
|
|
6
|
+
export type AppEntry = {
|
|
7
|
+
scripts: string[];
|
|
8
|
+
styles: string[];
|
|
9
|
+
};
|
|
10
|
+
export type AppConfigFile = {
|
|
11
|
+
assetsDir?: string;
|
|
12
|
+
entry?: AppEntry;
|
|
13
|
+
mountSelector?: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
routing?: string;
|
|
16
|
+
};
|
|
17
|
+
export type AppManifest = {
|
|
18
|
+
buildId: string;
|
|
19
|
+
entry: AppEntry;
|
|
20
|
+
files: AppFile[];
|
|
21
|
+
mountSelector: string;
|
|
22
|
+
name: string;
|
|
23
|
+
routing: 'hash';
|
|
24
|
+
};
|
|
25
|
+
export declare const APP_CONFIG_FILENAME = "loopress.app.json";
|
|
26
|
+
export declare function isAllowedAsset(relPath: string): boolean;
|
|
27
|
+
export declare function buildFileList(distDir: string): Promise<AppFile[]>;
|
|
28
|
+
export declare function computeBuildId(files: AppFile[]): string;
|
|
29
|
+
export declare function deriveEntry(indexHtml: string): AppEntry;
|
|
30
|
+
export declare function diffFiles(local: AppFile[], remote: AppFile[]): AppFile[];
|
|
31
|
+
export declare function parseAppConfig(raw: string, appDir: string): AppConfigFile;
|
|
32
|
+
export declare function loadAppManifest(appDir: string, dirName: string): Promise<{
|
|
33
|
+
configPath: string;
|
|
34
|
+
distDir: string;
|
|
35
|
+
manifest: AppManifest;
|
|
36
|
+
}>;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { extname, join, relative } from 'node:path';
|
|
4
|
+
export const APP_CONFIG_FILENAME = 'loopress.app.json';
|
|
5
|
+
const DEFAULT_ASSETS_DIR = 'dist';
|
|
6
|
+
// Static assets a bundler emits, mirrors the server's AppsDirectory::ALLOWED_EXTENSIONS.
|
|
7
|
+
// `.php` and friends are absent on purpose: these files are served straight off wp-content/.
|
|
8
|
+
const ALLOWED_EXTENSIONS = new Set([
|
|
9
|
+
'.avif', '.bmp', '.cjs', '.css', '.csv', '.eot', '.gif', '.htm', '.html', '.ico', '.jpeg',
|
|
10
|
+
'.jpg', '.js', '.json', '.map', '.mjs', '.mp3', '.mp4', '.ogg', '.otf',
|
|
11
|
+
'.pdf', '.png', '.svg', '.ttf', '.txt',
|
|
12
|
+
'.wasm', '.webm', '.webmanifest', '.webp', '.woff', '.woff2', '.xml',
|
|
13
|
+
]);
|
|
14
|
+
export function isAllowedAsset(relPath) {
|
|
15
|
+
return ALLOWED_EXTENSIONS.has(extname(relPath).toLowerCase());
|
|
16
|
+
}
|
|
17
|
+
async function walk(dir, root) {
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === 'ENOENT')
|
|
24
|
+
return [];
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const abs = join(dir, entry.name);
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
out.push(...(await walk(abs, root)));
|
|
32
|
+
}
|
|
33
|
+
else if (entry.isFile()) {
|
|
34
|
+
// Identity is always '/'-joined, never the OS separator: it travels over HTTP and is
|
|
35
|
+
// matched against the server's own '/'-joined paths.
|
|
36
|
+
out.push(relative(root, abs).replaceAll(/[\\/]/g, '/'));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
// Builds the sorted file list of a dist directory. Throws on a file whose extension the
|
|
42
|
+
// server would reject, so the push fails locally with a clear message instead of a 400
|
|
43
|
+
// halfway through the upload.
|
|
44
|
+
export async function buildFileList(distDir) {
|
|
45
|
+
const relPaths = (await walk(distDir, distDir)).sort((a, b) => a.localeCompare(b));
|
|
46
|
+
const files = [];
|
|
47
|
+
for (const path of relPaths) {
|
|
48
|
+
if (!isAllowedAsset(path)) {
|
|
49
|
+
throw new Error(`${path}: extension ${extname(path) || '(none)'} is not an allowed static asset. Remove it from the build output or the bundle.`);
|
|
50
|
+
}
|
|
51
|
+
const buf = await readFile(join(distDir, path));
|
|
52
|
+
files.push({ path, sha256: createHash('sha256').update(buf).digest('hex'), size: buf.length });
|
|
53
|
+
}
|
|
54
|
+
return files;
|
|
55
|
+
}
|
|
56
|
+
// A stable content hash of the whole build: changes if and only if some file's bytes change.
|
|
57
|
+
// Used as the enqueue `?ver` (cache busting) and as the build identity in `app list`.
|
|
58
|
+
export function computeBuildId(files) {
|
|
59
|
+
const digest = createHash('sha256');
|
|
60
|
+
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
61
|
+
digest.update(`${file.path}:${file.sha256}\n`);
|
|
62
|
+
}
|
|
63
|
+
return digest.digest('hex').slice(0, 12);
|
|
64
|
+
}
|
|
65
|
+
function normalizeAssetUrl(url) {
|
|
66
|
+
let path = url.trim();
|
|
67
|
+
const schemeMatch = /^(?:[a-z]+:)?\/\/[^/]+(\/.*)$/i.exec(path);
|
|
68
|
+
if (schemeMatch)
|
|
69
|
+
path = schemeMatch[1];
|
|
70
|
+
path = path.replace(/^\.?\//, '');
|
|
71
|
+
return path;
|
|
72
|
+
}
|
|
73
|
+
// Reads the <script type="module"> and <link rel="stylesheet"> the build's own index.html
|
|
74
|
+
// declares. This is what a bundler already writes; Loopress does not guess or reorder.
|
|
75
|
+
export function deriveEntry(indexHtml) {
|
|
76
|
+
const scripts = [];
|
|
77
|
+
for (const tag of indexHtml.match(/<script\b[^>]*>/gi) ?? []) {
|
|
78
|
+
if (!/\btype=["']module["']/i.test(tag))
|
|
79
|
+
continue;
|
|
80
|
+
const src = /\bsrc=["']([^"']+)["']/i.exec(tag);
|
|
81
|
+
if (src)
|
|
82
|
+
scripts.push(normalizeAssetUrl(src[1]));
|
|
83
|
+
}
|
|
84
|
+
const styles = [];
|
|
85
|
+
for (const tag of indexHtml.match(/<link\b[^>]*>/gi) ?? []) {
|
|
86
|
+
if (!/\brel=["']stylesheet["']/i.test(tag))
|
|
87
|
+
continue;
|
|
88
|
+
const href = /\bhref=["']([^"']+)["']/i.exec(tag);
|
|
89
|
+
if (href)
|
|
90
|
+
styles.push(normalizeAssetUrl(href[1]));
|
|
91
|
+
}
|
|
92
|
+
return { scripts, styles };
|
|
93
|
+
}
|
|
94
|
+
// Files new on this build or whose hash moved. Deletions are handled server-side on commit
|
|
95
|
+
// (with a one-generation grace for in-flight sessions), never by this diff.
|
|
96
|
+
export function diffFiles(local, remote) {
|
|
97
|
+
const remoteByPath = new Map(remote.map((file) => [file.path, file.sha256]));
|
|
98
|
+
return local.filter((file) => remoteByPath.get(file.path) !== file.sha256);
|
|
99
|
+
}
|
|
100
|
+
export function parseAppConfig(raw, appDir) {
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(raw);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw new Error(`${join(appDir, APP_CONFIG_FILENAME)} is not valid JSON`);
|
|
107
|
+
}
|
|
108
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
109
|
+
throw new Error(`${join(appDir, APP_CONFIG_FILENAME)} must be a JSON object`);
|
|
110
|
+
}
|
|
111
|
+
const config = parsed;
|
|
112
|
+
if (config.routing !== undefined && config.routing !== 'hash') {
|
|
113
|
+
throw new Error(`${join(appDir, APP_CONFIG_FILENAME)}: routing "${config.routing}" is not supported, only "hash" works without a server rewrite`);
|
|
114
|
+
}
|
|
115
|
+
return config;
|
|
116
|
+
}
|
|
117
|
+
// Assembles the full manifest for one app directory: reads loopress.app.json, hashes the
|
|
118
|
+
// dist directory, derives the entry from index.html (or takes it from the config), computes
|
|
119
|
+
// the build id.
|
|
120
|
+
export async function loadAppManifest(appDir, dirName) {
|
|
121
|
+
const configPath = join(appDir, APP_CONFIG_FILENAME);
|
|
122
|
+
const config = parseAppConfig(await readFile(configPath, 'utf8'), appDir);
|
|
123
|
+
const name = config.name ?? dirName;
|
|
124
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(name)) {
|
|
125
|
+
throw new Error(`Invalid app name "${name}": lowercase letters, digits and hyphens only, no leading or trailing hyphen`);
|
|
126
|
+
}
|
|
127
|
+
const distDir = join(appDir, config.assetsDir ?? DEFAULT_ASSETS_DIR);
|
|
128
|
+
const files = await buildFileList(distDir);
|
|
129
|
+
if (files.length === 0) {
|
|
130
|
+
throw new Error(`No files in ${distDir}. Build the app first, then run \`lps app push\`.`);
|
|
131
|
+
}
|
|
132
|
+
const filePaths = new Set(files.map((file) => file.path));
|
|
133
|
+
let entry;
|
|
134
|
+
if (config.entry) {
|
|
135
|
+
entry = config.entry;
|
|
136
|
+
}
|
|
137
|
+
else if (filePaths.has('index.html')) {
|
|
138
|
+
entry = deriveEntry(await readFile(join(distDir, 'index.html'), 'utf8'));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
throw new Error(`${distDir} has no index.html to read the entry from. Add an "entry" object to ${APP_CONFIG_FILENAME}.`);
|
|
142
|
+
}
|
|
143
|
+
const missing = [...entry.scripts, ...entry.styles].filter((ref) => !filePaths.has(ref));
|
|
144
|
+
if (missing.length > 0) {
|
|
145
|
+
throw new Error(`Entry references files not in the build output: ${missing.join(', ')}`);
|
|
146
|
+
}
|
|
147
|
+
if (entry.scripts.length === 0) {
|
|
148
|
+
throw new Error(`No <script type="module"> entry found for app "${name}".`);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
configPath,
|
|
152
|
+
distDir,
|
|
153
|
+
manifest: {
|
|
154
|
+
buildId: computeBuildId(files),
|
|
155
|
+
entry,
|
|
156
|
+
files,
|
|
157
|
+
mountSelector: config.mountSelector ?? `#loopress-app-${name}`,
|
|
158
|
+
name,
|
|
159
|
+
routing: 'hash',
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
package/dist/lib/base.d.ts
CHANGED
|
@@ -33,8 +33,10 @@ export declare abstract class LoopressCommand extends Command {
|
|
|
33
33
|
protected removeOrphanedFiles(dir: string, orphans: string[], reason: string): Promise<void>;
|
|
34
34
|
protected resolveAcfPath(override?: string): string;
|
|
35
35
|
protected resolveApiPath(override?: string): string;
|
|
36
|
+
protected resolveAppsPath(override?: string): string;
|
|
36
37
|
protected resolveFormPath(override?: string): string;
|
|
37
|
-
protected
|
|
38
|
+
protected resolveHooksPath(override?: string): string;
|
|
39
|
+
protected resolveOptionsPath(override?: string): string;
|
|
38
40
|
protected resolveSeoPath(override?: string): string;
|
|
39
41
|
protected resolveSnippetsPath(override?: string): string;
|
|
40
42
|
private pickEnvironment;
|
package/dist/lib/base.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
import { configManager } from '../config/project-config.manager.js';
|
|
7
7
|
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
8
8
|
import { pluralize } from '../utils/pluralize.js';
|
|
9
|
+
import { resolveResourceDir } from '../utils/resource-dirs.js';
|
|
9
10
|
import { isInteractive } from './interactive.js';
|
|
10
11
|
import { isAppPasswordStale, rotateAppPassword } from './rotate-app-password.js';
|
|
11
12
|
import { WpClient } from './wp-client.js';
|
|
@@ -132,34 +133,28 @@ export class LoopressCommand extends Command {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
resolveAcfPath(override) {
|
|
135
|
-
|
|
136
|
-
return override;
|
|
137
|
-
return join(this.rootDir, this.localConfig.acfDir ?? 'acf');
|
|
136
|
+
return resolveResourceDir('acf', this.localConfig, override);
|
|
138
137
|
}
|
|
139
138
|
resolveApiPath(override) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
return resolveResourceDir('api', this.localConfig, override);
|
|
140
|
+
}
|
|
141
|
+
resolveAppsPath(override) {
|
|
142
|
+
return resolveResourceDir('apps', this.localConfig, override);
|
|
143
143
|
}
|
|
144
144
|
resolveFormPath(override) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
return resolveResourceDir('form', this.localConfig, override);
|
|
146
|
+
}
|
|
147
|
+
resolveHooksPath(override) {
|
|
148
|
+
return resolveResourceDir('hooks', this.localConfig, override);
|
|
148
149
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return override;
|
|
152
|
-
return join(this.rootDir, this.localConfig.pageDir ?? 'pages');
|
|
150
|
+
resolveOptionsPath(override) {
|
|
151
|
+
return resolveResourceDir('options', this.localConfig, override);
|
|
153
152
|
}
|
|
154
153
|
resolveSeoPath(override) {
|
|
155
|
-
|
|
156
|
-
return override;
|
|
157
|
-
return join(this.rootDir, this.localConfig.seoDir ?? 'seo');
|
|
154
|
+
return resolveResourceDir('seo', this.localConfig, override);
|
|
158
155
|
}
|
|
159
156
|
resolveSnippetsPath(override) {
|
|
160
|
-
|
|
161
|
-
return override;
|
|
162
|
-
return join(this.rootDir, this.localConfig.snippetsDir ?? 'snippets');
|
|
157
|
+
return resolveResourceDir('snippets', this.localConfig, override);
|
|
163
158
|
}
|
|
164
159
|
pickEnvironment(project, envName) {
|
|
165
160
|
const env = project.environments[envName];
|
package/dist/lib/dev-watch.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type LoopressLocalConfig } from '../utils/loopress-config.js';
|
|
2
|
-
export type ResourceType = 'api' | '
|
|
2
|
+
export type ResourceType = 'api' | 'plugins' | 'snippets';
|
|
3
3
|
export declare const RESOURCE_TYPES: ResourceType[];
|
|
4
4
|
export type WatchTarget = {
|
|
5
5
|
commandId: string;
|
package/dist/lib/dev-watch.js
CHANGED
|
@@ -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', '
|
|
3
|
+
export const RESOURCE_TYPES = ['snippets', 'api', '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 = []) {
|
|
@@ -15,7 +15,6 @@ export function buildWatchTargets(types, localConfig, cwd) {
|
|
|
15
15
|
const rootDir = join(cwd, localConfig.rootDir ?? '.');
|
|
16
16
|
const candidates = {
|
|
17
17
|
api: { commandId: 'api:push', path: join(rootDir, localConfig.apiDir ?? 'api'), type: 'api' },
|
|
18
|
-
pages: { commandId: 'page:push', path: join(rootDir, localConfig.pageDir ?? 'pages'), type: 'pages' },
|
|
19
18
|
plugins: { commandId: 'plugin:push', path: join(cwd, 'loopress.json'), type: 'plugins' },
|
|
20
19
|
snippets: { commandId: 'snippet:push', path: join(rootDir, localConfig.snippetsDir ?? 'snippets'), type: 'snippets' },
|
|
21
20
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { LoopressCommand } from './base.js';
|
|
2
|
+
import { type ResourceState, type StateChange } from './diff-state.js';
|
|
3
|
+
import { type ResourceStateProvider } from './resource-state.js';
|
|
4
|
+
import { WpClient } from './wp-client.js';
|
|
5
|
+
export type ResourceDiffJson = {
|
|
6
|
+
added: string[];
|
|
7
|
+
changed: StateChange[];
|
|
8
|
+
error?: string;
|
|
9
|
+
removed: string[];
|
|
10
|
+
};
|
|
11
|
+
export type DiffJson = {
|
|
12
|
+
drift: boolean;
|
|
13
|
+
left: string;
|
|
14
|
+
resources: Record<string, ResourceDiffJson>;
|
|
15
|
+
right: string;
|
|
16
|
+
};
|
|
17
|
+
export type DiffTarget = {
|
|
18
|
+
left: () => Promise<ResourceState>;
|
|
19
|
+
resource: string;
|
|
20
|
+
right: () => Promise<ResourceState>;
|
|
21
|
+
title: string;
|
|
22
|
+
};
|
|
23
|
+
type DiffSides = {
|
|
24
|
+
againstWp: undefined | WpClient;
|
|
25
|
+
leftLabel: string;
|
|
26
|
+
rightLabel: string;
|
|
27
|
+
warn: (message: string) => void;
|
|
28
|
+
};
|
|
29
|
+
export declare abstract class DiffCommand extends LoopressCommand {
|
|
30
|
+
static againstFlag: {
|
|
31
|
+
against: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
32
|
+
};
|
|
33
|
+
protected composerTarget(sides: DiffSides): DiffTarget;
|
|
34
|
+
protected providerTarget(provider: ResourceStateProvider, sides: DiffSides, dirOverride?: string): DiffTarget;
|
|
35
|
+
protected report(targets: DiffTarget[], sides: DiffSides): Promise<DiffJson>;
|
|
36
|
+
protected resolveSides(against: string | undefined): DiffSides;
|
|
37
|
+
abstract run(): Promise<DiffJson>;
|
|
38
|
+
private computeResource;
|
|
39
|
+
private out;
|
|
40
|
+
private renderSection;
|
|
41
|
+
private wpForEnvironment;
|
|
42
|
+
}
|
|
43
|
+
export declare function resourceDiffCommand(resource: string, options: {
|
|
44
|
+
description: string;
|
|
45
|
+
pathNoun: string;
|
|
46
|
+
}): typeof DiffCommand;
|
|
47
|
+
export {};
|