@loopress/cli 0.21.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 (103) hide show
  1. package/README.md +457 -67
  2. package/dist/commands/acf/list.d.ts +2 -2
  3. package/dist/commands/acf/list.js +2 -5
  4. package/dist/commands/acf/pull.js +9 -22
  5. package/dist/commands/acf/push.js +3 -6
  6. package/dist/commands/api/list.js +2 -1
  7. package/dist/commands/api/publish.js +6 -22
  8. package/dist/commands/api/pull.js +12 -17
  9. package/dist/commands/api/push.js +7 -10
  10. package/dist/commands/app/list.d.ts +17 -0
  11. package/dist/commands/app/list.js +35 -0
  12. package/dist/commands/app/pull.d.ts +27 -0
  13. package/dist/commands/app/pull.js +94 -0
  14. package/dist/commands/app/push.d.ts +28 -0
  15. package/dist/commands/app/push.js +130 -0
  16. package/dist/commands/app/remove.d.ts +20 -0
  17. package/dist/commands/app/remove.js +42 -0
  18. package/dist/commands/composer/push.d.ts +1 -0
  19. package/dist/commands/composer/push.js +43 -19
  20. package/dist/commands/dev.js +1 -1
  21. package/dist/commands/doctor.js +2 -1
  22. package/dist/commands/form/list.d.ts +2 -4
  23. package/dist/commands/form/list.js +3 -10
  24. package/dist/commands/form/pull.js +15 -24
  25. package/dist/commands/form/push.js +17 -42
  26. package/dist/commands/init.js +1 -0
  27. package/dist/commands/login.js +6 -2
  28. package/dist/commands/logout.js +2 -1
  29. package/dist/commands/page/pull.js +17 -28
  30. package/dist/commands/page/push.js +27 -52
  31. package/dist/commands/plugin/add.d.ts +1 -0
  32. package/dist/commands/plugin/add.js +18 -8
  33. package/dist/commands/plugin/audit.d.ts +30 -0
  34. package/dist/commands/plugin/audit.js +138 -0
  35. package/dist/commands/plugin/pull.d.ts +1 -1
  36. package/dist/commands/plugin/pull.js +13 -15
  37. package/dist/commands/plugin/push.d.ts +14 -5
  38. package/dist/commands/plugin/push.js +171 -60
  39. package/dist/commands/plugin/status.d.ts +22 -0
  40. package/dist/commands/plugin/status.js +65 -0
  41. package/dist/commands/project/config.js +4 -1
  42. package/dist/commands/project/list.d.ts +16 -1
  43. package/dist/commands/project/list.js +22 -16
  44. package/dist/commands/project/pull.js +3 -2
  45. package/dist/commands/project/push.d.ts +5 -0
  46. package/dist/commands/project/push.js +79 -58
  47. package/dist/commands/promote.d.ts +18 -0
  48. package/dist/commands/promote.js +90 -0
  49. package/dist/commands/pull.d.ts +12 -0
  50. package/dist/commands/pull.js +51 -0
  51. package/dist/commands/push.d.ts +13 -0
  52. package/dist/commands/push.js +57 -0
  53. package/dist/commands/seo/list.d.ts +9 -2
  54. package/dist/commands/seo/list.js +9 -11
  55. package/dist/commands/seo/pull.js +14 -37
  56. package/dist/commands/seo/push.js +24 -48
  57. package/dist/commands/snippet/list.js +2 -1
  58. package/dist/commands/snippet/publish.js +5 -21
  59. package/dist/commands/snippet/pull.js +15 -20
  60. package/dist/commands/snippet/push.js +21 -30
  61. package/dist/commands/theme/add.d.ts +13 -0
  62. package/dist/commands/theme/add.js +35 -0
  63. package/dist/commands/theme/pull.d.ts +21 -0
  64. package/dist/commands/theme/pull.js +37 -0
  65. package/dist/commands/theme/push.d.ts +23 -0
  66. package/dist/commands/theme/push.js +130 -0
  67. package/dist/commands/theme/status.d.ts +20 -0
  68. package/dist/commands/theme/status.js +51 -0
  69. package/dist/commands/validate.d.ts +8 -0
  70. package/dist/commands/validate.js +24 -0
  71. package/dist/lib/app-manifest.d.ts +36 -0
  72. package/dist/lib/app-manifest.js +162 -0
  73. package/dist/lib/base.d.ts +10 -0
  74. package/dist/lib/base.js +56 -22
  75. package/dist/lib/guard-production-push.d.ts +12 -0
  76. package/dist/lib/guard-production-push.js +20 -0
  77. package/dist/lib/load-snippets.js +65 -60
  78. package/dist/lib/local-callback-server.js +9 -4
  79. package/dist/lib/push-command.d.ts +3 -0
  80. package/dist/lib/push-command.js +22 -15
  81. package/dist/lib/put-or-create.d.ts +13 -0
  82. package/dist/lib/put-or-create.js +19 -0
  83. package/dist/lib/readdir-tolerant.d.ts +5 -0
  84. package/dist/lib/readdir-tolerant.js +11 -0
  85. package/dist/lib/resolve-linked-project.d.ts +8 -0
  86. package/dist/lib/resolve-linked-project.js +27 -0
  87. package/dist/lib/validate-local.d.ts +10 -0
  88. package/dist/lib/validate-local.js +183 -0
  89. package/dist/types/plugin.d.ts +1 -0
  90. package/dist/types/project-config.generated.d.ts +11 -1
  91. package/dist/utils/plugin-sync.d.ts +26 -0
  92. package/dist/utils/plugin-sync.js +31 -0
  93. package/dist/utils/plugins.d.ts +15 -2
  94. package/dist/utils/plugins.js +57 -12
  95. package/dist/utils/pluralize.d.ts +1 -0
  96. package/dist/utils/pluralize.js +5 -0
  97. package/dist/utils/themes.d.ts +30 -0
  98. package/dist/utils/themes.js +29 -0
  99. package/dist/utils/version.d.ts +2 -0
  100. package/dist/utils/version.js +21 -0
  101. package/oclif.manifest.json +986 -125
  102. package/package.json +25 -20
  103. package/schemas/project-config.schema.json +22 -4
@@ -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,8 @@
1
+ import { Command } from '@oclif/core';
2
+ import { type ValidateResult } from '../lib/validate-local.js';
3
+ export default class Validate extends Command {
4
+ static description: string;
5
+ static enableJsonFlag: boolean;
6
+ static examples: string[];
7
+ run(): Promise<ValidateResult>;
8
+ }
@@ -0,0 +1,24 @@
1
+ import { Command } from '@oclif/core';
2
+ import { validateLocal } from '../lib/validate-local.js';
3
+ export default class Validate extends Command {
4
+ static description = 'Check local tracked files are well formed and push-ready, without contacting WordPress';
5
+ static enableJsonFlag = true;
6
+ static examples = ['$ lps validate'];
7
+ async run() {
8
+ await this.parse(Validate);
9
+ const result = await validateLocal(process.cwd());
10
+ if (!this.jsonEnabled()) {
11
+ for (const problem of result.problems) {
12
+ this.log(` ✗ ${problem.file}: ${problem.message}`);
13
+ }
14
+ this.log('');
15
+ this.log(result.valid
16
+ ? `${result.checked} file(s) checked, no problems found.`
17
+ : `${result.checked} file(s) checked, ${result.problems.length} problem(s) found.`);
18
+ }
19
+ // Non-zero exit for CI without throwing, so `--json` still prints the full report.
20
+ if (!result.valid)
21
+ process.exitCode = 1;
22
+ return result;
23
+ }
24
+ }
@@ -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
+ }
@@ -22,13 +22,23 @@ export declare abstract class LoopressCommand extends Command {
22
22
  protected get wp(): WpClient;
23
23
  init(): Promise<void>;
24
24
  protected maybeAutoRotate(): Promise<void>;
25
+ protected pullDirectory<T>(dir: string, items: T[], orphans: string[], options: {
26
+ alwaysCreateDir?: boolean;
27
+ dryRunMessage: string;
28
+ orphanReason: string;
29
+ pulledMessage: string;
30
+ title: (item: T) => string;
31
+ write: (item: T, dir: string) => Promise<void>;
32
+ }): Promise<void>;
25
33
  protected removeOrphanedFiles(dir: string, orphans: string[], reason: string): Promise<void>;
26
34
  protected resolveAcfPath(override?: string): string;
27
35
  protected resolveApiPath(override?: string): string;
36
+ protected resolveAppsPath(override?: string): string;
28
37
  protected resolveFormPath(override?: string): string;
29
38
  protected resolvePagePath(override?: string): string;
30
39
  protected resolveSeoPath(override?: string): string;
31
40
  protected resolveSnippetsPath(override?: string): string;
32
41
  private pickEnvironment;
33
42
  private resolveEnvironment;
43
+ private resolveEnvironmentForConfiguredProject;
34
44
  }
package/dist/lib/base.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { confirm } from '@inquirer/prompts';
2
2
  import { Command, Flags } from '@oclif/core';
3
- import { rm } from 'node:fs/promises';
3
+ import { Listr } from 'listr2';
4
+ import { mkdir, rm } from 'node:fs/promises';
4
5
  import { join } from 'node:path';
5
6
  import { configManager } from '../config/project-config.manager.js';
6
7
  import { readLocalConfig } from '../utils/loopress-config.js';
8
+ import { pluralize } from '../utils/pluralize.js';
7
9
  import { isInteractive } from './interactive.js';
8
10
  import { isAppPasswordStale, rotateAppPassword } from './rotate-app-password.js';
9
11
  import { WpClient } from './wp-client.js';
@@ -77,6 +79,31 @@ export class LoopressCommand extends Command {
77
79
  // swallowed: see comment above
78
80
  }
79
81
  }
82
+ // Shared shape of every resource's directory pull: write each item, then reconcile local
83
+ // files against the remote list (see removeOrphanedFiles above). `dryRunMessage` and
84
+ // `pulledMessage` are fully-formed by the caller (label pluralization varies per resource,
85
+ // e.g. ACF's `type` is a fixed category name, never pluralized), the "[dry-run] Would "
86
+ // prefix and the orphans notice are the only wording this method owns.
87
+ async pullDirectory(dir, items, orphans, options) {
88
+ if (this.dryRun) {
89
+ this.log(`[dry-run] ${options.dryRunMessage}`);
90
+ if (orphans.length > 0) {
91
+ this.log(`[dry-run] Would remove ${pluralize(orphans.length, 'local file')} ${options.orphanReason}: ${orphans.join(', ')}`);
92
+ }
93
+ return;
94
+ }
95
+ if (options.alwaysCreateDir === true || items.length > 0)
96
+ await mkdir(dir, { recursive: true });
97
+ await new Listr(items.map((item) => ({
98
+ async task(_ctx, task) {
99
+ await options.write(item, dir);
100
+ task.output = `Pulled: ${options.title(item)}`;
101
+ },
102
+ title: `Pull ${options.title(item)}`,
103
+ })), { renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
104
+ await this.removeOrphanedFiles(dir, orphans, options.orphanReason);
105
+ this.log(options.pulledMessage);
106
+ }
80
107
  // Orphan cleanup shared by every pull command (see Push Deletion Rules in the product docs):
81
108
  // in a TTY the list is shown and confirmed first (Enter accepts), --yes skips the prompt, and
82
109
  // outside a TTY the files are removed with a warning, the behavior scripts relied on before
@@ -84,7 +111,7 @@ export class LoopressCommand extends Command {
84
111
  async removeOrphanedFiles(dir, orphans, reason) {
85
112
  if (orphans.length === 0)
86
113
  return;
87
- const description = `${orphans.length} local file${orphans.length === 1 ? '' : 's'} ${reason}: ${orphans.join(', ')}`;
114
+ const description = `${pluralize(orphans.length, 'local file')} ${reason}: ${orphans.join(', ')}`;
88
115
  if (!this.yes && isInteractive()) {
89
116
  const isProceed = await confirm({ default: true, message: `Remove ${description}?` });
90
117
  if (!isProceed) {
@@ -114,6 +141,11 @@ export class LoopressCommand extends Command {
114
141
  return override;
115
142
  return join(this.rootDir, this.localConfig.apiDir ?? 'api');
116
143
  }
144
+ resolveAppsPath(override) {
145
+ if (override)
146
+ return override;
147
+ return join(this.rootDir, this.localConfig.appsDir ?? 'apps');
148
+ }
117
149
  resolveFormPath(override) {
118
150
  if (override)
119
151
  return override;
@@ -143,26 +175,7 @@ export class LoopressCommand extends Command {
143
175
  }
144
176
  resolveEnvironment(envName) {
145
177
  if (this.localConfig.projectId) {
146
- const { projectId } = this.localConfig;
147
- const project = configManager.getProject(projectId);
148
- if (!project) {
149
- this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
150
- }
151
- const envNames = Object.keys(project.environments);
152
- if (envNames.length === 0) {
153
- this.error(`Project "${project.name}" has no environments configured. Run \`lps project config\` to add one.`);
154
- }
155
- if (envName)
156
- return { env: this.pickEnvironment(project, envName), projectId };
157
- if (envNames.length === 1) {
158
- return { env: project.environments[envNames[0]], projectId };
159
- }
160
- const current = configManager.getCurrentProject();
161
- const currentEnv = current?.id === projectId ? configManager.getCurrentEnv() : null;
162
- if (!currentEnv) {
163
- this.error(`Project "${project.name}" has multiple environments. Run \`lps project switch\` to pick one.`);
164
- }
165
- return { env: currentEnv, projectId };
178
+ return this.resolveEnvironmentForConfiguredProject(this.localConfig.projectId, envName);
166
179
  }
167
180
  if (envName) {
168
181
  const current = configManager.getCurrentProject();
@@ -177,4 +190,25 @@ export class LoopressCommand extends Command {
177
190
  return { env, projectId: current.id };
178
191
  this.error('No environment configured. Run `lps project config` first.');
179
192
  }
193
+ resolveEnvironmentForConfiguredProject(projectId, envName) {
194
+ const project = configManager.getProject(projectId);
195
+ if (!project) {
196
+ this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
197
+ }
198
+ const envNames = Object.keys(project.environments);
199
+ if (envNames.length === 0) {
200
+ this.error(`Project "${project.name}" has no environments configured. Run \`lps project config\` to add one.`);
201
+ }
202
+ if (envName)
203
+ return { env: this.pickEnvironment(project, envName), projectId };
204
+ if (envNames.length === 1) {
205
+ return { env: project.environments[envNames[0]], projectId };
206
+ }
207
+ const current = configManager.getCurrentProject();
208
+ const currentEnv = current?.id === projectId ? configManager.getCurrentEnv() : null;
209
+ if (!currentEnv) {
210
+ this.error(`Project "${project.name}" has multiple environments. Run \`lps project switch\` to pick one.`);
211
+ }
212
+ return { env: currentEnv, projectId };
213
+ }
180
214
  }
@@ -0,0 +1,12 @@
1
+ type GuardProductionPushOptions = {
2
+ dryRun: boolean;
3
+ error: (message: string) => never;
4
+ onRefuse?: () => void;
5
+ siteConfig: {
6
+ name: string;
7
+ url: string;
8
+ };
9
+ yes: boolean;
10
+ };
11
+ export declare function guardProductionPush(options: GuardProductionPushOptions): Promise<void>;
12
+ export {};
@@ -0,0 +1,20 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { isInteractive } from './interactive.js';
3
+ // Pushing to an environment named "production" needs explicit intent: a TTY confirmation
4
+ // (Enter accepts), or --yes in scripts and CI. Dry runs are exempt, they change nothing.
5
+ // Shared by PushCommand (lib/push-command.ts, guards a single resource push) and the
6
+ // top-level `lps push` (commands/push.ts, guards once before delegating to 8 sub-commands).
7
+ export async function guardProductionPush(options) {
8
+ const { dryRun, error, onRefuse, siteConfig, yes } = options;
9
+ if (dryRun || yes || siteConfig.name.toLowerCase() !== 'production')
10
+ return;
11
+ if (!isInteractive()) {
12
+ onRefuse?.();
13
+ error('Target environment is "production". Pass --yes to confirm the push in a non-interactive run.');
14
+ }
15
+ const isProceed = await confirm({ default: true, message: `Push to production (${siteConfig.url})?` });
16
+ if (!isProceed) {
17
+ onRefuse?.();
18
+ error('Aborted.');
19
+ }
20
+ }