@loopress/cli 0.22.0 → 0.23.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 (47) hide show
  1. package/README.md +335 -58
  2. package/dist/commands/app/list.d.ts +17 -0
  3. package/dist/commands/app/list.js +35 -0
  4. package/dist/commands/app/pull.d.ts +27 -0
  5. package/dist/commands/app/pull.js +94 -0
  6. package/dist/commands/app/push.d.ts +28 -0
  7. package/dist/commands/app/push.js +130 -0
  8. package/dist/commands/app/remove.d.ts +20 -0
  9. package/dist/commands/app/remove.js +42 -0
  10. package/dist/commands/composer/push.d.ts +1 -0
  11. package/dist/commands/composer/push.js +43 -19
  12. package/dist/commands/init.js +1 -0
  13. package/dist/commands/plugin/add.d.ts +1 -0
  14. package/dist/commands/plugin/add.js +18 -8
  15. package/dist/commands/plugin/audit.d.ts +30 -0
  16. package/dist/commands/plugin/audit.js +138 -0
  17. package/dist/commands/plugin/pull.d.ts +1 -2
  18. package/dist/commands/plugin/pull.js +11 -11
  19. package/dist/commands/plugin/push.d.ts +14 -8
  20. package/dist/commands/plugin/push.js +168 -67
  21. package/dist/commands/plugin/status.d.ts +22 -0
  22. package/dist/commands/plugin/status.js +65 -0
  23. package/dist/commands/theme/add.d.ts +13 -0
  24. package/dist/commands/theme/add.js +35 -0
  25. package/dist/commands/theme/pull.d.ts +21 -0
  26. package/dist/commands/theme/pull.js +37 -0
  27. package/dist/commands/theme/push.d.ts +23 -0
  28. package/dist/commands/theme/push.js +130 -0
  29. package/dist/commands/theme/status.d.ts +20 -0
  30. package/dist/commands/theme/status.js +51 -0
  31. package/dist/lib/app-manifest.d.ts +36 -0
  32. package/dist/lib/app-manifest.js +162 -0
  33. package/dist/lib/base.d.ts +1 -0
  34. package/dist/lib/base.js +5 -0
  35. package/dist/types/plugin.d.ts +1 -0
  36. package/dist/types/project-config.generated.d.ts +11 -1
  37. package/dist/utils/plugin-sync.d.ts +26 -0
  38. package/dist/utils/plugin-sync.js +31 -0
  39. package/dist/utils/plugins.d.ts +15 -2
  40. package/dist/utils/plugins.js +57 -12
  41. package/dist/utils/themes.d.ts +30 -0
  42. package/dist/utils/themes.js +29 -0
  43. package/dist/utils/version.d.ts +2 -0
  44. package/dist/utils/version.js +21 -0
  45. package/oclif.manifest.json +714 -68
  46. package/package.json +22 -18
  47. package/schemas/project-config.schema.json +22 -4
@@ -0,0 +1,21 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ type PullResult = {
3
+ added: string[];
4
+ merged: Record<string, string>;
5
+ status: 'composer-managed' | 'dry-run' | 'success';
6
+ updated: Array<{
7
+ from: string;
8
+ slug: string;
9
+ to: string;
10
+ }>;
11
+ };
12
+ export default class Pull extends LoopressCommand {
13
+ static description: string;
14
+ static enableJsonFlag: boolean;
15
+ static examples: string[];
16
+ static flags: {
17
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ };
19
+ run(): Promise<PullResult>;
20
+ }
21
+ export {};
@@ -0,0 +1,37 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { LoopressCommand } from '../../lib/base.js';
4
+ import { writeLocalConfig } from '../../utils/loopress-config.js';
5
+ import { mergePluginManifest } from '../../utils/plugins.js';
6
+ import { parseInstalledThemes } from '../../utils/themes.js';
7
+ export default class Pull extends LoopressCommand {
8
+ static description = 'Pull installed themes from WordPress into loopress.json, pinned to their live versions';
9
+ static enableJsonFlag = true;
10
+ static examples = ['$ lps theme pull'];
11
+ static flags = {
12
+ ...LoopressCommand.dryRunFlag,
13
+ };
14
+ async run() {
15
+ const { url } = this.siteConfig;
16
+ if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
17
+ this.warn('This project has a composer.json, which is authoritative. Run `lps composer pull` instead.');
18
+ return { added: [], merged: this.localConfig.themes ?? {}, status: 'composer-managed', updated: [] };
19
+ }
20
+ this.log(`Pulling themes from ${url}`);
21
+ const raw = await this.wp.get('wp/v2/themes');
22
+ const installed = parseInstalledThemes(raw);
23
+ const incoming = Object.fromEntries(installed.map((t) => [t.slug, t.version]));
24
+ const { added, merged, updated } = mergePluginManifest(this.localConfig.themes ?? {}, incoming);
25
+ if (this.dryRun) {
26
+ this.log(`[dry-run] Would write ${Object.keys(merged).length} themes to loopress.json`);
27
+ return { added, merged, status: 'dry-run', updated };
28
+ }
29
+ await writeLocalConfig({ ...this.localConfig, themes: merged });
30
+ this.log(`Wrote ${Object.keys(merged).length} themes to loopress.json`);
31
+ if (added.length > 0)
32
+ this.log(` + Added: ${added.join(', ')}`);
33
+ for (const u of updated)
34
+ this.log(` ~ Updated: ${u.slug} ${u.from} → ${u.to}`);
35
+ return { added, merged, status: 'success', updated };
36
+ }
37
+ }
@@ -0,0 +1,23 @@
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 confirmRemovals;
19
+ private fetchInstanceLock;
20
+ private guardForce;
21
+ private sync;
22
+ }
23
+ export {};
@@ -0,0 +1,130 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Flags } from '@oclif/core';
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { isInteractive } from '../../lib/interactive.js';
6
+ import { PushCommand } from '../../lib/push-command.js';
7
+ import { isNotFoundError } from '../../lib/wp-client.js';
8
+ import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS } from '../../utils/plugin-sync.js';
9
+ import { lockedWpackagistSlugs } from '../../utils/plugins.js';
10
+ import { diffThemes, parseInstalledThemes } from '../../utils/themes.js';
11
+ const IN_SYNC = { installed: [], pinned: [], removed: [], status: 'in-sync' };
12
+ export default class Push extends PushCommand {
13
+ static description = 'Install themes on WordPress to match loopress.json (versions only, never switches the active theme)';
14
+ static enableJsonFlag = true;
15
+ static examples = ['$ lps theme push', '$ lps theme push --force'];
16
+ static flags = {
17
+ ...PushCommand.dryRunFlag,
18
+ ...PushCommand.yesFlag,
19
+ force: Flags.boolean({ default: false, description: 'Allow downgrades and take over themes installed outside Loopress' }),
20
+ };
21
+ async run() {
22
+ const { flags } = await this.parse(Push);
23
+ const { force } = flags;
24
+ if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
25
+ this.warn('This project has a composer.json, which is authoritative. Run `lps composer push` instead.');
26
+ return { ...IN_SYNC, status: 'composer-managed' };
27
+ }
28
+ const manifest = this.localConfig.themes ?? {};
29
+ if (Object.keys(manifest).length === 0) {
30
+ this.error('No themes found in loopress.json. Run `lps theme pull` first.');
31
+ }
32
+ this.log(`Pushing themes to ${this.siteConfig.url}`);
33
+ const raw = await this.wp.get('wp/v2/themes');
34
+ const installed = parseInstalledThemes(raw);
35
+ const managed = lockedWpackagistSlugs(await this.fetchInstanceLock(), 'theme');
36
+ const diff = diffThemes(manifest, installed, managed);
37
+ this.guardForce(diff, force);
38
+ // Uninstalling the theme WordPress is currently rendering leaves the site with no active
39
+ // theme, which is a hard fatal. --force does not override this: switch the active theme
40
+ // on the site first, then the removal is safe. A --force takeover of a collision hits the
41
+ // same fatal: the server moves the existing folder out of the way before reinstalling it,
42
+ // so the active theme is briefly missing either way, an active collision is just as
43
+ // dangerous as an active removal.
44
+ const activeRemoval = [...diff.toRemove, ...diff.collisions.map((c) => c.slug)].find((slug) => installed.find((t) => t.slug === slug)?.active);
45
+ if (activeRemoval) {
46
+ this.error(`Refusing to uninstall "${activeRemoval}": it is the site's active theme. ` +
47
+ 'Switch the site to another theme, then re-run to remove it.');
48
+ }
49
+ // A "latest" pin never shows as drift, but its newest upstream release may have moved, so
50
+ // a push must still run `composer update` to pick it up.
51
+ const hasLatestPin = Object.values(manifest).includes('latest');
52
+ if (!hasLatestPin && isNoop(diff)) {
53
+ this.log('Everything is already in sync.');
54
+ return { ...IN_SYNC };
55
+ }
56
+ if (isNoop(diff))
57
+ this.log('Refreshing themes pinned to "latest" to their newest releases.');
58
+ for (const a of diff.toInstall)
59
+ this.log(` + ${a.slug} ${a.version}`);
60
+ for (const p of diff.toPin)
61
+ this.log(` ~ ${p.slug} ${p.from} to ${p.to}`);
62
+ for (const s of diff.toRemove)
63
+ this.log(` - ${s}`);
64
+ for (const c of diff.collisions)
65
+ this.log(` ! ${c.slug} (take over)`);
66
+ if (this.dryRun)
67
+ return result(diff, 'dry-run', diff.toRemove, true);
68
+ await this.confirmRemovals(diff.toRemove);
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 confirmRemovals(toRemove) {
77
+ if (toRemove.length === 0 || this.yes || !isInteractive())
78
+ return;
79
+ const ok = await confirm({ default: false, message: `Uninstall ${toRemove.join(', ')} from the site?` });
80
+ if (!ok)
81
+ this.error('Aborted.');
82
+ }
83
+ async fetchInstanceLock() {
84
+ try {
85
+ const { composerLock } = await this.wp.get('loopress/v1/composer/lock');
86
+ return composerLock;
87
+ }
88
+ catch (error) {
89
+ if (isNotFoundError(error))
90
+ return null;
91
+ throw error;
92
+ }
93
+ }
94
+ guardForce(diff, force) {
95
+ if (force)
96
+ return;
97
+ if (diff.collisions.length > 0) {
98
+ const list = diff.collisions.map((c) => `${c.slug} (${c.installedVersion})`).join(', ');
99
+ this.error(`${list} installed outside Loopress. Re-run with --force to take them over, or remove them from loopress.json.`);
100
+ }
101
+ const downgrades = diff.toPin.filter((p) => isDowngrade(p.from, p.to));
102
+ if (downgrades.length > 0) {
103
+ const list = downgrades.map((p) => `${p.slug} ${p.from} to ${p.to}`).join(', ');
104
+ this.error(`Refusing to downgrade: ${list}. Re-run with --force.`);
105
+ }
106
+ }
107
+ async sync(manifest, force) {
108
+ try {
109
+ return await this.wp.post('loopress/v1/composer/sync', { force, intent: { themes: manifest }, lock: null }, { timeoutMs: SYNC_TIMEOUT_MS });
110
+ }
111
+ catch (error) {
112
+ if (parseCollisions(error)) {
113
+ this.error('The site rejected the push: themes installed outside Loopress. Re-run with --force.');
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+ }
119
+ function isNoop(diff) {
120
+ return (diff.toInstall.length === 0 && diff.toPin.length === 0 && diff.toRemove.length === 0 && diff.collisions.length === 0);
121
+ }
122
+ function result(diff, status, removed, includeCollisions) {
123
+ const collisionSlugs = includeCollisions ? diff.collisions.map((c) => c.slug) : [];
124
+ return {
125
+ installed: [...diff.toInstall.map((a) => a.slug), ...collisionSlugs],
126
+ pinned: diff.toPin.map((p) => p.slug),
127
+ removed,
128
+ status,
129
+ };
130
+ }
@@ -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
+ }
@@ -33,6 +33,7 @@ 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
38
  protected resolvePagePath(override?: string): string;
38
39
  protected resolveSeoPath(override?: string): string;
package/dist/lib/base.js CHANGED
@@ -141,6 +141,11 @@ export class LoopressCommand extends Command {
141
141
  return override;
142
142
  return join(this.rootDir, this.localConfig.apiDir ?? 'api');
143
143
  }
144
+ resolveAppsPath(override) {
145
+ if (override)
146
+ return override;
147
+ return join(this.rootDir, this.localConfig.appsDir ?? 'apps');
148
+ }
144
149
  resolveFormPath(override) {
145
150
  if (override)
146
151
  return override;
@@ -8,6 +8,7 @@ export type InstalledPlugin = {
8
8
  export type WpNativePlugin = {
9
9
  name: string;
10
10
  plugin: string;
11
+ plugin_uri: string;
11
12
  status: 'active' | 'inactive' | 'network-active';
12
13
  version: string;
13
14
  };
@@ -31,14 +31,24 @@ export interface LoopressProjectConfiguration {
31
31
  * Directory where custom API route files are read from and written to, relative to rootDir.
32
32
  */
33
33
  apiDir?: string;
34
+ /**
35
+ * Directory where single-page app bundles are read from and written to, relative to rootDir. Each subdirectory is one app: a loopress.app.json plus a built dist/ folder.
36
+ */
37
+ appsDir?: string;
34
38
  /**
35
39
  * Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
36
40
  */
37
41
  projectId?: string;
38
42
  /**
39
- * WordPress.org plugins managed by Loopress. Keys are plugin slugs; values are written as "latest" since installs go through WordPress's native plugin API, which only installs the current version. Older files may still have a pinned version string here from before this was the case; it is read but no longer acted on.
43
+ * WordPress.org plugins managed by Loopress, installed via Composer + WPackagist on the site. Keys are plugin slugs. A value is either an exact version to pin (e.g. "9.4.2") or "latest" to track the newest release on every push. Removing an entry uninstalls the plugin on the next push. Ignored when a composer.json exists in rootDir (that file becomes authoritative).
40
44
  */
41
45
  plugins?: {
42
46
  [k: string]: string;
43
47
  };
48
+ /**
49
+ * WordPress.org themes managed by Loopress, installed via Composer + WPackagist on the site. Keys are theme slugs; values are an exact version or "latest". Loopress manages installed theme versions only, it never switches the active theme. Ignored when a composer.json exists in rootDir.
50
+ */
51
+ themes?: {
52
+ [k: string]: string;
53
+ };
44
54
  }
@@ -0,0 +1,26 @@
1
+ export declare const SYNC_TIMEOUT_MS = 600000;
2
+ export type SyncIntent = {
3
+ libraries?: Record<string, string>;
4
+ plugins?: Record<string, string>;
5
+ themes?: Record<string, string>;
6
+ };
7
+ export type SyncPayload = {
8
+ force: boolean;
9
+ intent: SyncIntent;
10
+ lock: null | string;
11
+ };
12
+ export type SyncResponse = {
13
+ composerJson: string;
14
+ composerLock: string;
15
+ message: string;
16
+ output: string;
17
+ removed: string[];
18
+ };
19
+ export type Collision = {
20
+ installedVersion: string;
21
+ path: string;
22
+ slug: string;
23
+ type: 'plugin' | 'theme';
24
+ };
25
+ export declare function parseCollisions(error: unknown): Collision[] | null;
26
+ export declare function isDowngrade(from: string, to: string): boolean;
@@ -0,0 +1,31 @@
1
+ // Shared helpers for driving the server-side `loopress/v1/composer/sync` endpoint, used by
2
+ // `lps plugin push`, `lps theme push` and `lps composer push`. The endpoint runs Composer +
3
+ // WPackagist inside WordPress; the CLI only ever sends intent (which slugs at which versions),
4
+ // never a composer.json, so the plugin stays the single authority on that file's shape.
5
+ import { compareVersions } from './version.js';
6
+ // A cold `composer update` on a fresh site easily exceeds the global 30s WpClient timeout.
7
+ export const SYNC_TIMEOUT_MS = 600_000;
8
+ // 422 from the sync endpoint when the intent references a plugin/theme whose folder already
9
+ // exists on the site but isn't tracked in composer.lock (installed by hand through wp-admin).
10
+ // Re-running with --force lets the server remove the folder so Composer can take it over.
11
+ export function parseCollisions(error) {
12
+ const body = error.cause?.response;
13
+ if (body?.statusCode !== 422 || !body.body)
14
+ return null;
15
+ try {
16
+ const parsed = JSON.parse(body.body);
17
+ if (parsed.error === 'unmanaged_plugins_present' && Array.isArray(parsed.collisions)) {
18
+ return parsed.collisions;
19
+ }
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ return null;
25
+ }
26
+ // Only gates whether a version change needs --force. Unorderable inputs ("latest", a
27
+ // constraint) count as "not a downgrade" so they don't block the push.
28
+ export function isDowngrade(from, to) {
29
+ const order = compareVersions(to, from);
30
+ return order !== null && order < 0;
31
+ }
@@ -1,13 +1,25 @@
1
1
  import { type InstalledPlugin, type PluginManifest, type WpNativePlugin } from '../types/plugin.js';
2
2
  export type PluginDiff = {
3
+ collisions: Array<{
4
+ installedVersion: string;
5
+ slug: string;
6
+ }>;
7
+ inSync: string[];
3
8
  toActivate: Array<{
4
9
  file: string;
5
10
  slug: string;
6
11
  }>;
7
12
  toInstall: Array<{
8
13
  slug: string;
14
+ version: string;
15
+ }>;
16
+ toPin: Array<{
17
+ from: string;
18
+ slug: string;
19
+ to: string;
9
20
  }>;
10
- upToDate: string[];
21
+ toRemove: string[];
22
+ untrackedActive: string[];
11
23
  };
12
24
  export type MergeResult = {
13
25
  added: string[];
@@ -20,4 +32,5 @@ export type MergeResult = {
20
32
  };
21
33
  export declare function mergePluginManifest(existing: PluginManifest, incoming: PluginManifest): MergeResult;
22
34
  export declare function parseInstalledPlugins(raw: WpNativePlugin[]): InstalledPlugin[];
23
- export declare function diffPlugins(manifest: PluginManifest, installed: InstalledPlugin[]): PluginDiff;
35
+ export declare function diffPlugins(manifest: PluginManifest, installed: InstalledPlugin[], managedSlugs?: Set<string>): PluginDiff;
36
+ export declare function lockedWpackagistSlugs(composerLock: null | string, kind: 'plugin' | 'theme'): Set<string>;