@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,17 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ type RemoteApp = {
3
+ buildId: null | string;
4
+ committed: boolean;
5
+ deployedAt: null | string;
6
+ fileCount: number;
7
+ name: string;
8
+ routing: null | string;
9
+ totalBytes: number;
10
+ };
11
+ export default class List extends LoopressCommand {
12
+ static description: string;
13
+ static enableJsonFlag: boolean;
14
+ static examples: string[];
15
+ run(): Promise<RemoteApp[]>;
16
+ }
17
+ export {};
@@ -0,0 +1,35 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ import { pluralize } from '../../utils/pluralize.js';
3
+ function humanBytes(bytes) {
4
+ if (bytes < 1024)
5
+ return `${bytes} B`;
6
+ if (bytes < 1024 * 1024)
7
+ return `${(bytes / 1024).toFixed(1)} KB`;
8
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
9
+ }
10
+ export default class List extends LoopressCommand {
11
+ static description = 'List single-page apps deployed to WordPress';
12
+ static enableJsonFlag = true;
13
+ static examples = ['$ lps app list'];
14
+ async run() {
15
+ const apps = await this.wp.get('loopress/v1/apps');
16
+ this.log(`Apps (${apps.length}):`);
17
+ if (apps.length === 0) {
18
+ this.log(' (none)');
19
+ return apps;
20
+ }
21
+ for (const app of apps) {
22
+ this.log(` ${app.name}`);
23
+ if (app.committed) {
24
+ this.log(` Build: ${app.buildId ?? '(unknown)'}`);
25
+ this.log(` Files: ${pluralize(app.fileCount, 'file')}, ${humanBytes(app.totalBytes)}`);
26
+ if (app.deployedAt)
27
+ this.log(` Deployed: ${app.deployedAt}`);
28
+ }
29
+ else {
30
+ this.log(' (uploaded but never committed, run `lps app push` to finish)');
31
+ }
32
+ }
33
+ return apps;
34
+ }
35
+ }
@@ -0,0 +1,27 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ type PulledApp = {
3
+ files: number;
4
+ name: string;
5
+ };
6
+ type PullResult = {
7
+ orphans: string[];
8
+ pulled: PulledApp[];
9
+ status: 'dry-run' | 'success';
10
+ };
11
+ export default class Pull extends LoopressCommand {
12
+ static args: {
13
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
14
+ };
15
+ static description: string;
16
+ static enableJsonFlag: boolean;
17
+ static examples: string[];
18
+ static flags: {
19
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
+ };
22
+ run(): Promise<PullResult>;
23
+ private findOrphanApps;
24
+ private pullApp;
25
+ private removeOrphanApps;
26
+ }
27
+ export {};
@@ -0,0 +1,94 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Args } from '@oclif/core';
3
+ import { Buffer } from 'node:buffer';
4
+ import { existsSync } from 'node:fs';
5
+ import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
6
+ import { dirname, join } from 'node:path';
7
+ import { APP_CONFIG_FILENAME } from '../../lib/app-manifest.js';
8
+ import { LoopressCommand } from '../../lib/base.js';
9
+ import { isInteractive } from '../../lib/interactive.js';
10
+ import { pluralize } from '../../utils/pluralize.js';
11
+ export default class Pull extends LoopressCommand {
12
+ static args = {
13
+ path: Args.string({ description: 'Path to apps directory (overrides project config)' }),
14
+ };
15
+ static description = 'Pull single-page app bundles from WordPress into local files';
16
+ static enableJsonFlag = true;
17
+ static examples = ['$ lps app pull'];
18
+ static flags = {
19
+ ...LoopressCommand.dryRunFlag,
20
+ ...LoopressCommand.yesFlag,
21
+ };
22
+ async run() {
23
+ const { args } = await this.parse(Pull);
24
+ const { url } = this.siteConfig;
25
+ const path = this.resolveAppsPath(args.path);
26
+ this.log(`Pulling apps from ${url}`);
27
+ this.log(`Apps path: ${path}`);
28
+ const remoteApps = await this.wp.get('loopress/v1/apps');
29
+ const pullable = remoteApps.filter((app) => app.committed);
30
+ const orphans = await this.findOrphanApps(path, new Set(pullable.map((app) => app.name)));
31
+ if (this.dryRun) {
32
+ this.log(`[dry-run] Would pull ${pluralize(pullable.length, 'app')} to ${path}`);
33
+ if (orphans.length > 0) {
34
+ this.log(`[dry-run] Would remove ${pluralize(orphans.length, 'local app')}: ${orphans.join(', ')}`);
35
+ }
36
+ return { orphans, pulled: pullable.map((app) => ({ files: 0, name: app.name })), status: 'dry-run' };
37
+ }
38
+ const pulled = [];
39
+ for (const app of pullable) {
40
+ pulled.push(await this.pullApp(path, app.name));
41
+ }
42
+ await this.removeOrphanApps(path, orphans);
43
+ this.log(`Pulled ${pluralize(pulled.length, 'app')} to ${path}`);
44
+ return { orphans, pulled, status: 'success' };
45
+ }
46
+ async findOrphanApps(path, keep) {
47
+ let entries;
48
+ try {
49
+ entries = await readdir(path, { withFileTypes: true });
50
+ }
51
+ catch (error) {
52
+ if (error.code === 'ENOENT')
53
+ return [];
54
+ throw error;
55
+ }
56
+ return entries
57
+ .filter((entry) => entry.isDirectory())
58
+ .map((entry) => entry.name)
59
+ .filter((name) => existsSync(join(path, name, APP_CONFIG_FILENAME)) && !keep.has(name));
60
+ }
61
+ async pullApp(path, name) {
62
+ const manifest = await this.wp.get(`loopress/v1/apps/${name}/manifest`);
63
+ const distDir = join(path, name, 'dist');
64
+ for (const file of manifest.files) {
65
+ const { content } = await this.wp.get(`loopress/v1/apps/${name}/asset?path=${encodeURIComponent(file.path)}`);
66
+ const target = join(distDir, file.path);
67
+ await mkdir(dirname(target), { recursive: true });
68
+ await writeFile(target, Buffer.from(content, 'base64'));
69
+ }
70
+ await writeFile(join(path, name, APP_CONFIG_FILENAME), JSON.stringify({ assetsDir: 'dist', mountSelector: manifest.mountSelector, name: manifest.name, routing: manifest.routing }, null, 2) + '\n');
71
+ this.log(` ${name}: ${pluralize(manifest.files.length, 'file')} (build ${manifest.buildId})`);
72
+ return { files: manifest.files.length, name };
73
+ }
74
+ async removeOrphanApps(path, orphans) {
75
+ if (orphans.length === 0)
76
+ return;
77
+ const description = `${pluralize(orphans.length, 'local app')} no longer on WordPress: ${orphans.join(', ')}`;
78
+ if (!this.yes && isInteractive()) {
79
+ const isProceed = await confirm({ default: true, message: `Remove ${description}?` });
80
+ if (!isProceed) {
81
+ this.log(`Kept ${description}`);
82
+ return;
83
+ }
84
+ }
85
+ for (const name of orphans)
86
+ await rm(join(path, name), { force: true, recursive: true });
87
+ if (this.yes || isInteractive()) {
88
+ this.log(`Removed ${description}`);
89
+ }
90
+ else {
91
+ this.warn(`Removed ${description}`);
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,28 @@
1
+ import { PushCommand } from '../../lib/push-command.js';
2
+ type PushedApp = {
3
+ buildId: string;
4
+ name: string;
5
+ uploaded: number;
6
+ };
7
+ type PushResult = {
8
+ pushed: PushedApp[];
9
+ status: 'dry-run' | 'success';
10
+ };
11
+ export default class Push extends PushCommand {
12
+ static args: {
13
+ name: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
14
+ };
15
+ static description: string;
16
+ static enableJsonFlag: boolean;
17
+ static examples: string[];
18
+ static flags: {
19
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
20
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
+ };
22
+ run(): Promise<PushResult>;
23
+ private appDirNames;
24
+ private pushApp;
25
+ private remoteFiles;
26
+ private uploadAsset;
27
+ }
28
+ export {};
@@ -0,0 +1,130 @@
1
+ import { Args } from '@oclif/core';
2
+ import { existsSync } from 'node:fs';
3
+ import { readdir, readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { APP_CONFIG_FILENAME, diffFiles, loadAppManifest, } from '../../lib/app-manifest.js';
6
+ import { PushCommand } from '../../lib/push-command.js';
7
+ import { isNotFoundError } from '../../lib/wp-client.js';
8
+ import { pluralize } from '../../utils/pluralize.js';
9
+ export default class Push extends PushCommand {
10
+ static args = {
11
+ name: Args.string({ description: 'Push only this app (defaults to every app in the directory)' }),
12
+ };
13
+ static description = 'Push built single-page app bundles to WordPress. Each app is an `apps/<name>/` directory holding a loopress.app.json and a built dist/ folder. Only files whose content changed are uploaded.';
14
+ static enableJsonFlag = true;
15
+ static examples = ['$ lps app push', '$ lps app push search'];
16
+ static flags = {
17
+ ...PushCommand.dryRunFlag,
18
+ ...PushCommand.yesFlag,
19
+ };
20
+ async run() {
21
+ const { args } = await this.parse(Push);
22
+ const { url } = this.siteConfig;
23
+ const path = this.resolveAppsPath();
24
+ this.log(`Pushing apps to ${url}`);
25
+ this.log(`Apps path: ${path}`);
26
+ const dirNames = await this.appDirNames(path, args.name);
27
+ this.log(`Found ${pluralize(dirNames.length, 'app')} to push`);
28
+ const pushed = [];
29
+ await this.runPushTasks(dirNames, (dirName) => dirName, async (dirName, task) => {
30
+ const result = await this.pushApp(join(path, dirName), dirName, task);
31
+ if (result)
32
+ pushed.push(result);
33
+ });
34
+ if (this.failedCount > 0) {
35
+ this.error(`${pluralize(this.failedCount, 'app')} failed to push.`);
36
+ }
37
+ if (this.dryRun)
38
+ return { pushed, status: 'dry-run' };
39
+ await this.recordSuccess();
40
+ this.log('All apps pushed.');
41
+ return { pushed, status: 'success' };
42
+ }
43
+ async appDirNames(path, only) {
44
+ let entries;
45
+ try {
46
+ entries = await readdir(path, { withFileTypes: true });
47
+ }
48
+ catch (error) {
49
+ if (error.code === 'ENOENT') {
50
+ this.error(`Apps directory not found: ${path}. Create ${join(path, '<name>', APP_CONFIG_FILENAME)} first.`);
51
+ }
52
+ throw error;
53
+ }
54
+ const names = entries
55
+ .filter((entry) => entry.isDirectory())
56
+ .map((entry) => entry.name)
57
+ .filter((name) => existsSync(join(path, name, APP_CONFIG_FILENAME)))
58
+ .sort((a, b) => a.localeCompare(b));
59
+ if (only) {
60
+ if (!names.includes(only)) {
61
+ this.error(`No app "${only}" in ${path} (looked for ${join(only, APP_CONFIG_FILENAME)}).`);
62
+ }
63
+ return [only];
64
+ }
65
+ if (names.length === 0) {
66
+ this.error(`No apps found in ${path}. Each app needs a ${APP_CONFIG_FILENAME} and a built dist/ folder.`);
67
+ }
68
+ return names;
69
+ }
70
+ async pushApp(appDir, dirName, task) {
71
+ let manifest;
72
+ let distDir;
73
+ try {
74
+ ;
75
+ ({ distDir, manifest } = await loadAppManifest(appDir, dirName));
76
+ }
77
+ catch (error) {
78
+ this.reportTaskFailure(`${dirName}: ${error.message}`, error, task);
79
+ }
80
+ const remote = await this.remoteFiles(manifest.name);
81
+ const changed = diffFiles(manifest.files, remote);
82
+ if (this.dryRun) {
83
+ if (task) {
84
+ task.output =
85
+ changed.length === 0
86
+ ? `[dry-run] ${manifest.name}: up to date (build ${manifest.buildId})`
87
+ : `[dry-run] ${manifest.name}: would upload ${pluralize(changed.length, 'file')}, then commit build ${manifest.buildId}`;
88
+ }
89
+ return undefined;
90
+ }
91
+ for (const [index, file] of changed.entries()) {
92
+ if (task)
93
+ task.output = `${manifest.name}: uploading ${file.path} (${index + 1}/${changed.length})`;
94
+ await this.uploadAsset(manifest.name, distDir, file);
95
+ }
96
+ try {
97
+ await this.wp.post(`loopress/v1/apps/${manifest.name}/commit`, manifest);
98
+ }
99
+ catch (error) {
100
+ this.reportTaskFailure(`${manifest.name}: commit failed: ${error.message}`, error, task);
101
+ }
102
+ if (task) {
103
+ task.output =
104
+ changed.length === 0
105
+ ? `${manifest.name}: already up to date (build ${manifest.buildId})`
106
+ : `${manifest.name}: uploaded ${pluralize(changed.length, 'file')}, committed build ${manifest.buildId}`;
107
+ }
108
+ return { buildId: manifest.buildId, name: manifest.name, uploaded: changed.length };
109
+ }
110
+ async remoteFiles(name) {
111
+ try {
112
+ const remote = await this.wp.get(`loopress/v1/apps/${name}/manifest`);
113
+ return remote.files ?? [];
114
+ }
115
+ catch (error) {
116
+ if (isNotFoundError(error))
117
+ return [];
118
+ throw error;
119
+ }
120
+ }
121
+ async uploadAsset(name, distDir, file) {
122
+ const content = (await readFile(join(distDir, file.path))).toString('base64');
123
+ try {
124
+ await this.wp.put(`loopress/v1/apps/${name}/assets`, { content, encoding: 'base64', path: file.path });
125
+ }
126
+ catch (error) {
127
+ this.reportTaskFailure(`${name}: failed to upload ${file.path}: ${error.message}`, error);
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,20 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ type RemoveResult = {
3
+ deleted: boolean;
4
+ name: string;
5
+ status: 'aborted' | 'dry-run' | 'success';
6
+ };
7
+ export default class Remove extends LoopressCommand {
8
+ static args: {
9
+ name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
10
+ };
11
+ static description: string;
12
+ static enableJsonFlag: boolean;
13
+ static examples: string[];
14
+ static flags: {
15
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ };
18
+ run(): Promise<RemoveResult>;
19
+ }
20
+ export {};
@@ -0,0 +1,42 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Args } from '@oclif/core';
3
+ import { LoopressCommand } from '../../lib/base.js';
4
+ import { guardProductionPush } from '../../lib/guard-production-push.js';
5
+ import { isInteractive } from '../../lib/interactive.js';
6
+ export default class Remove extends LoopressCommand {
7
+ static args = {
8
+ name: Args.string({ description: 'App to remove from WordPress', required: true }),
9
+ };
10
+ static description = 'Remove a single-page app from WordPress: deletes its bundle from wp-content/loopress/apps/ and unregisters the shortcode. Local files are left untouched.';
11
+ static enableJsonFlag = true;
12
+ static examples = ['$ lps app remove search'];
13
+ static flags = {
14
+ ...LoopressCommand.dryRunFlag,
15
+ ...LoopressCommand.yesFlag,
16
+ };
17
+ async run() {
18
+ const { args } = await this.parse(Remove);
19
+ const { name } = args;
20
+ const { url } = this.siteConfig;
21
+ if (this.dryRun) {
22
+ this.log(`[dry-run] Would delete app "${name}" from ${url}`);
23
+ return { deleted: false, name, status: 'dry-run' };
24
+ }
25
+ await guardProductionPush({
26
+ dryRun: this.dryRun,
27
+ error: (message) => this.error(message),
28
+ siteConfig: this.siteConfig,
29
+ yes: this.yes,
30
+ });
31
+ if (!this.yes && isInteractive()) {
32
+ const isProceed = await confirm({ default: false, message: `Delete app "${name}" from ${url}?` });
33
+ if (!isProceed) {
34
+ this.log('Aborted.');
35
+ return { deleted: false, name, status: 'aborted' };
36
+ }
37
+ }
38
+ await this.wp.delete(`loopress/v1/apps/${name}`);
39
+ this.log(`Removed app "${name}" from ${url}`);
40
+ return { deleted: true, name, status: 'success' };
41
+ }
42
+ }
@@ -9,6 +9,7 @@ export default class ComposerPush extends PushCommand {
9
9
  static enableJsonFlag: boolean;
10
10
  static examples: string[];
11
11
  static flags: {
12
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
13
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
14
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
15
  };
@@ -1,53 +1,77 @@
1
+ import { Flags } from '@oclif/core';
1
2
  import { existsSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { PushCommand } from '../../lib/push-command.js';
5
6
  import { isTimeoutError } from '../../lib/wp-client.js';
6
- // A cold composer install easily exceeds the global 30s timeout of WpClient.
7
- const COMPOSER_SYNC_TIMEOUT_MS = 600_000;
7
+ import { parseCollisions, SYNC_TIMEOUT_MS } from '../../utils/plugin-sync.js';
8
+ // Split a composer.json `require` map into the three intent namespaces the sync endpoint
9
+ // understands. `composer/installers` is owned by the server scaffold, never sent.
10
+ function toIntent(require) {
11
+ const intent = { libraries: {}, plugins: {}, themes: {} };
12
+ for (const [name, constraint] of Object.entries(require)) {
13
+ if (name === 'composer/installers')
14
+ continue;
15
+ if (name.startsWith('wpackagist-plugin/')) {
16
+ intent.plugins[name.slice('wpackagist-plugin/'.length)] = constraint;
17
+ }
18
+ else if (name.startsWith('wpackagist-theme/')) {
19
+ intent.themes[name.slice('wpackagist-theme/'.length)] = constraint;
20
+ }
21
+ else {
22
+ intent.libraries[name] = constraint;
23
+ }
24
+ }
25
+ return intent;
26
+ }
8
27
  export default class ComposerPush extends PushCommand {
9
- static description = 'Push composer.json and composer.lock to WordPress and run composer install';
28
+ static description = 'Push composer.json (and composer.lock, if present) to WordPress and run Composer';
10
29
  static enableJsonFlag = true;
11
30
  static examples = ['$ lps composer push', '$ lps composer push --dry-run'];
12
31
  static flags = {
13
32
  ...PushCommand.dryRunFlag,
14
33
  ...PushCommand.yesFlag,
34
+ force: Flags.boolean({ default: false, description: 'Allow downgrades and take over plugins/themes installed outside Loopress' }),
15
35
  };
16
36
  async run() {
17
37
  const { url } = this.siteConfig;
38
+ const { flags } = await this.parse(ComposerPush);
39
+ const { force } = flags;
18
40
  const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
19
41
  const composerLockPath = join(process.cwd(), this.rootDir, 'composer.lock');
20
42
  if (!existsSync(composerJsonPath)) {
21
- this.error(`No composer.json found at ${composerJsonPath}`);
43
+ this.error(`No composer.json found at ${composerJsonPath}. Run \`lps composer init\` first.`);
22
44
  }
23
- const composerJsonRaw = await readFile(composerJsonPath, 'utf8');
24
- const parsed = JSON.parse(composerJsonRaw);
25
- const packageCount = Object.keys(parsed.require ?? {}).length;
45
+ const parsed = JSON.parse(await readFile(composerJsonPath, 'utf8'));
46
+ const require = parsed.require ?? {};
47
+ const packageCount = Object.keys(require).length;
48
+ const intent = toIntent(require);
26
49
  const hasLock = existsSync(composerLockPath);
27
- const composerLockRaw = hasLock ? await readFile(composerLockPath, 'utf8') : null;
50
+ const lock = hasLock ? await readFile(composerLockPath, 'utf8') : null;
28
51
  this.log(`Pushing composer.json (${packageCount} ${packageCount === 1 ? 'package' : 'packages'}) to ${url}`);
29
- if (composerLockRaw) {
52
+ if (lock)
30
53
  this.log(' + composer.lock included (reproducible install)');
31
- }
32
- else {
54
+ else
33
55
  this.warn('No composer.lock found. The server will resolve versions freely.');
34
- }
35
56
  if (this.dryRun)
36
57
  return { hasLock, packageCount, status: 'dry-run' };
37
- this.log('Running composer install on the server, this can take a few minutes...');
58
+ this.log('Running Composer on the server, this can take a few minutes...');
59
+ let response;
38
60
  try {
39
- await this.wp.post('loopress/v1/composer/sync', {
40
- composerJson: composerJsonRaw,
41
- composerLock: composerLockRaw,
42
- }, { timeoutMs: COMPOSER_SYNC_TIMEOUT_MS });
61
+ response = await this.wp.post('loopress/v1/composer/sync', { force, intent, lock }, { timeoutMs: SYNC_TIMEOUT_MS });
43
62
  }
44
63
  catch (error) {
64
+ if (parseCollisions(error)) {
65
+ this.error('Plugins or themes are installed outside Loopress. Re-run with --force to take them over.');
66
+ }
45
67
  if (isTimeoutError(error)) {
46
- this.error(`${error.message} The composer install may still be running on the server.`);
68
+ this.error(`${error.message} The Composer run may still be in progress on the server.`);
47
69
  }
48
70
  throw error;
49
71
  }
50
- this.log('composer install completed on the server.');
72
+ if (response.output.trim())
73
+ this.log(response.output.trim());
74
+ this.log('Composer run completed on the server.');
51
75
  await this.recordSuccess();
52
76
  return { hasLock, packageCount, status: 'success' };
53
77
  }
@@ -18,6 +18,7 @@ const FEATURES = [
18
18
  { dir: 'forms', key: 'formDir', label: 'Forms' },
19
19
  { dir: 'pages', key: 'pageDir', label: 'Pages' },
20
20
  { dir: 'api', key: 'apiDir', label: 'Custom API routes' },
21
+ { dir: 'apps', key: 'appsDir', label: 'Single-page apps' },
21
22
  ];
22
23
  export default class Init extends Command {
23
24
  static description = 'Initialize a loopress.json config file in the current directory';
@@ -6,6 +6,7 @@ export default class Add extends LoopressCommand {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ version: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  };
11
12
  run(): Promise<void>;
@@ -1,32 +1,42 @@
1
- import { Args } from '@oclif/core';
1
+ import { Args, Flags } from '@oclif/core';
2
2
  import { LoopressCommand } from '../../lib/base.js';
3
3
  import { writeLocalConfig } from '../../utils/loopress-config.js';
4
+ import { isExactVersion } from '../../utils/version.js';
4
5
  export default class Add extends LoopressCommand {
5
6
  static args = {
6
7
  slug: Args.string({ description: 'Plugin slug on WordPress.org', required: true }),
7
8
  };
8
9
  static description = 'Add a WordPress.org plugin to loopress.json';
9
- static examples = ['$ lps plugin add woocommerce', '$ lps plugin add contact-form-7 --dry-run'];
10
+ static examples = [
11
+ '$ lps plugin add woocommerce',
12
+ '$ lps plugin add woocommerce --version 9.4.2',
13
+ '$ lps plugin add contact-form-7 --dry-run',
14
+ ];
10
15
  static flags = {
11
16
  ...LoopressCommand.dryRunFlag,
17
+ version: Flags.string({ description: 'Exact version to pin (default: "latest", tracked on every push)' }),
12
18
  };
13
19
  async run() {
14
- const { args } = await this.parse(Add);
20
+ const { args, flags } = await this.parse(Add);
15
21
  const { slug } = args;
22
+ const version = flags.version ?? 'latest';
23
+ if (version !== 'latest' && !isExactVersion(version)) {
24
+ this.error(`--version must be an exact version like 9.4.2, not a Composer constraint. Got "${version}".`);
25
+ }
16
26
  const existing = this.localConfig.plugins ?? {};
17
- if (existing[slug] === 'latest') {
18
- this.log(`${slug} is already in loopress.json, nothing to do.`);
27
+ if (existing[slug] === version) {
28
+ this.log(`${slug} is already pinned to ${version} in loopress.json, nothing to do.`);
19
29
  return;
20
30
  }
21
31
  const isUpdated = existing[slug] !== undefined;
22
32
  if (this.dryRun) {
23
- this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} in loopress.json`);
33
+ this.log(`[dry-run] Would ${isUpdated ? 'update' : 'add'} ${slug} (${version}) in loopress.json`);
24
34
  return;
25
35
  }
26
36
  await writeLocalConfig({
27
37
  ...this.localConfig,
28
- plugins: { ...existing, [slug]: 'latest' },
38
+ plugins: { ...existing, [slug]: version },
29
39
  });
30
- this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug}`);
40
+ this.log(`${isUpdated ? 'Updated' : 'Added'} ${slug} (${version}). Run \`lps plugin push\` to apply.`);
31
41
  }
32
42
  }
@@ -0,0 +1,30 @@
1
+ import { LoopressCommand } from '../../lib/base.js';
2
+ type Advisory = {
3
+ cve?: string;
4
+ fixedIn?: string;
5
+ score?: number;
6
+ severity?: string;
7
+ slug: string;
8
+ title: string;
9
+ };
10
+ type HealthNote = {
11
+ message: string;
12
+ slug: string;
13
+ };
14
+ type AuditResult = {
15
+ advisories: Advisory[];
16
+ health: HealthNote[];
17
+ status: 'clean' | 'issues';
18
+ };
19
+ export default class Audit extends LoopressCommand {
20
+ static description: string;
21
+ static enableJsonFlag: boolean;
22
+ static examples: string[];
23
+ static flags: {};
24
+ run(): Promise<AuditResult>;
25
+ private auditOne;
26
+ private fetchHealth;
27
+ private fetchVulnerabilities;
28
+ private report;
29
+ }
30
+ export {};