@loopress/cli 0.14.0 → 0.16.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.
@@ -0,0 +1,47 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { existsSync } from 'node:fs';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { LoopressCommand } from '../../lib/base.js';
6
+ const WPACKAGIST_REPOSITORY = { type: 'composer', url: 'https://wpackagist.org' };
7
+ const INSTALLERS_PACKAGE = 'composer/installers';
8
+ const INSTALLERS_CONSTRAINT = '^2.0';
9
+ // The server runs Composer with `--working-dir` set to wp-content/loopress/ (see
10
+ // LoopressEnvironment::getDxDir), not the WordPress root, so installer-paths must climb out
11
+ // of that directory to land plugins/themes in their usual wp-content/ locations.
12
+ const INSTALLER_PATHS = {
13
+ '../plugins/{$name}/': ['type:wordpress-plugin'],
14
+ '../themes/{$name}/': ['type:wordpress-theme'],
15
+ };
16
+ export default class ComposerInit extends LoopressCommand {
17
+ static description = 'Create a composer.json wired to WPackagist for installing WordPress.org plugins and themes';
18
+ static examples = ['$ lps composer init', '$ lps composer init --dry-run'];
19
+ static flags = {
20
+ ...LoopressCommand.dryRunFlag,
21
+ };
22
+ async run() {
23
+ const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
24
+ if (existsSync(composerJsonPath)) {
25
+ const overwrite = await confirm({ default: false, message: 'composer.json already exists. Overwrite?' });
26
+ if (!overwrite) {
27
+ this.log('Aborted.');
28
+ return;
29
+ }
30
+ }
31
+ const composerJson = {
32
+ extra: { 'installer-paths': INSTALLER_PATHS },
33
+ name: 'loopress/site-dependencies',
34
+ repositories: [WPACKAGIST_REPOSITORY],
35
+ require: {
36
+ [INSTALLERS_PACKAGE]: INSTALLERS_CONSTRAINT,
37
+ },
38
+ };
39
+ if (this.dryRun) {
40
+ this.log(`[dry-run] Would write composer.json to ${composerJsonPath}`);
41
+ return;
42
+ }
43
+ await writeFile(composerJsonPath, JSON.stringify(composerJson, null, 2) + '\n', 'utf8');
44
+ this.log(`Wrote composer.json to ${composerJsonPath}`);
45
+ this.log('Add plugins with `wpackagist-plugin/<slug>` and a theme with `wpackagist-theme/<slug>` in require, then run `lps composer push`.');
46
+ }
47
+ }
@@ -2,21 +2,24 @@ import { writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { LoopressCommand } from '../../lib/base.js';
4
4
  export default class ComposerPull extends LoopressCommand {
5
- static description = 'Pull composer.lock from WordPress';
5
+ static description = 'Pull composer.json and composer.lock from WordPress';
6
6
  static examples = ['$ lps composer pull', '$ lps composer pull --dry-run'];
7
7
  static flags = {
8
8
  ...LoopressCommand.dryRunFlag,
9
9
  };
10
10
  async run() {
11
11
  const { url } = this.siteConfig;
12
- this.log(`Pulling composer.lock from ${url}`);
12
+ this.log(`Pulling composer.json and composer.lock from ${url}`);
13
+ const { composerJson } = await this.wp.get('loopress/v1/composer/json');
13
14
  const { composerLock } = await this.wp.get('loopress/v1/composer/lock');
14
15
  if (this.dryRun) {
15
- this.log('[dry-run] Would write composer.lock');
16
+ this.log('[dry-run] Would write composer.json and composer.lock');
16
17
  return;
17
18
  }
19
+ const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
18
20
  const lockPath = join(process.cwd(), this.rootDir, 'composer.lock');
21
+ await writeFile(composerJsonPath, composerJson, 'utf8');
19
22
  await writeFile(lockPath, composerLock, 'utf8');
20
- this.log(`Wrote composer.lock`);
23
+ this.log(`Wrote composer.json and composer.lock`);
21
24
  }
22
25
  }
@@ -0,0 +1,8 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Pull extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ run(): Promise<void>;
6
+ private fetchApiProjects;
7
+ private pullProject;
8
+ }
@@ -0,0 +1,69 @@
1
+ import { Command } from '@oclif/core';
2
+ import { Listr } from 'listr2';
3
+ import { authManager } from '../../config/auth.manager.js';
4
+ import { configManager } from '../../config/project-config.manager.js';
5
+ import { ApiClient } from '../../lib/api-client.js';
6
+ export default class Pull extends Command {
7
+ static description = 'Pull projects and environments from your Loopress account that are not configured locally yet';
8
+ static examples = ['$ lps project pull'];
9
+ async run() {
10
+ await this.parse(Pull);
11
+ const token = authManager.getAuth()?.token;
12
+ if (!token) {
13
+ this.error('Not logged in. Run `lps login` first.');
14
+ }
15
+ const api = new ApiClient(token);
16
+ const apiProjects = await this.fetchApiProjects(api);
17
+ const linkedApiProjectIds = new Set(configManager
18
+ .listProjects()
19
+ .map((project) => project.apiProjectId)
20
+ .filter((id) => id !== undefined));
21
+ const newApiProjects = apiProjects.filter((apiProject) => !linkedApiProjectIds.has(apiProject.id));
22
+ let projectCount = 0;
23
+ let environmentCount = 0;
24
+ if (newApiProjects.length > 0) {
25
+ await new Listr(newApiProjects.map((apiProject) => ({
26
+ task: (_ctx, task) => {
27
+ this.pullProject(apiProject, task);
28
+ projectCount++;
29
+ environmentCount += apiProject.environments.length;
30
+ },
31
+ title: `Pull project "${apiProject.name}" from the API`,
32
+ })), { concurrent: false, exitOnError: false }).run();
33
+ }
34
+ this.log(`\n✓ Pulled ${projectCount} project${projectCount === 1 ? '' : 's'}, ${environmentCount} environment${environmentCount === 1 ? '' : 's'} from your Loopress account`);
35
+ }
36
+ async fetchApiProjects(api) {
37
+ try {
38
+ return await api.get('projects');
39
+ }
40
+ catch (error) {
41
+ this.error(`Could not fetch projects from the API: ${error.message}`);
42
+ }
43
+ }
44
+ // Reuse a local project already linked to this API project instead of always minting a new
45
+ // one: otherwise every pull that can't "claim" an existing link (e.g. after the local config
46
+ // was reset or desynced from the API) creates yet another duplicate entry.
47
+ pullProject(apiProject, task) {
48
+ const existing = configManager.findProjectByApiId(apiProject.id);
49
+ const environments = { ...existing?.environments };
50
+ for (const env of apiProject.environments) {
51
+ environments[env.name] = {
52
+ ...environments[env.name],
53
+ addedAt: environments[env.name]?.addedAt ?? env.createdAt,
54
+ apiEnvironmentId: env.id,
55
+ name: env.name,
56
+ url: env.url,
57
+ };
58
+ }
59
+ configManager.setProject(existing?.id ?? configManager.createProjectId(apiProject.name), {
60
+ addedAt: existing?.addedAt ?? apiProject.createdAt,
61
+ apiProjectId: apiProject.id,
62
+ environments,
63
+ name: apiProject.name,
64
+ });
65
+ const envCount = apiProject.environments.length;
66
+ if (task)
67
+ task.output = `Pulled with ${envCount} environment${envCount === 1 ? '' : 's'}`;
68
+ }
69
+ }
@@ -1,5 +1,5 @@
1
1
  import { Command } from '@oclif/core';
2
- export default class Sync extends Command {
2
+ export default class Push extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  run(): Promise<void>;
@@ -8,6 +8,5 @@ export default class Sync extends Command {
8
8
  private fetchApiProjects;
9
9
  private planEnvironment;
10
10
  private planProject;
11
- private pullProject;
12
11
  private pushCredentials;
13
12
  }
@@ -5,11 +5,11 @@ import slugify from 'slugify';
5
5
  import { authManager } from '../../config/auth.manager.js';
6
6
  import { configManager } from '../../config/project-config.manager.js';
7
7
  import { ApiClient } from '../../lib/api-client.js';
8
- export default class Sync extends Command {
9
- static description = 'Sync locally configured projects and environments with your Loopress account';
10
- static examples = ['$ lps project sync'];
8
+ export default class Push extends Command {
9
+ static description = 'Push locally configured projects, environments and credentials to your Loopress account';
10
+ static examples = ['$ lps project push'];
11
11
  async run() {
12
- await this.parse(Sync);
12
+ await this.parse(Push);
13
13
  const token = authManager.getAuth()?.token;
14
14
  if (!token) {
15
15
  this.error('Not logged in. Run `lps login` first.');
@@ -77,22 +77,11 @@ export default class Sync extends Command {
77
77
  await this.pushCredentials(api, plan.apiProjectId, envPlan.apiEnvironmentId, envPlan.env);
78
78
  }
79
79
  catch (error) {
80
- this.warn(`Failed to sync "${plan.project.name}/${envPlan.env.name}": ${error.message}`);
80
+ this.warn(`Failed to push "${plan.project.name}/${envPlan.env.name}": ${error.message}`);
81
81
  }
82
82
  }
83
83
  }
84
- const newApiProjects = apiProjects.filter((candidate) => !claimedProjectIds.has(candidate.id));
85
- if (newApiProjects.length > 0) {
86
- await new Listr(newApiProjects.map((apiProject) => ({
87
- task: async (_ctx, task) => {
88
- this.pullProject(apiProject, task);
89
- projectCount++;
90
- environmentCount += apiProject.environments.length;
91
- },
92
- title: `Pull project "${apiProject.name}" from the API`,
93
- })), { concurrent: false, exitOnError: false }).run();
94
- }
95
- this.log(`\n✓ Synced ${projectCount} project${projectCount === 1 ? '' : 's'}, ${environmentCount} environment${environmentCount === 1 ? '' : 's'} with your Loopress account`);
84
+ this.log(`\n✓ Pushed ${projectCount} project${projectCount === 1 ? '' : 's'}, ${environmentCount} environment${environmentCount === 1 ? '' : 's'} to your Loopress account`);
96
85
  }
97
86
  async applyEnvironment(api, apiProjectId, envPlan, task) {
98
87
  try {
@@ -111,7 +100,7 @@ export default class Sync extends Command {
111
100
  task.output = envPlan.action === 'create' ? 'Created on the API' : 'Linked to the API';
112
101
  }
113
102
  catch (error) {
114
- const message = `Failed to sync "${envPlan.env.name}": ${error.message}`;
103
+ const message = `Failed to push "${envPlan.env.name}": ${error.message}`;
115
104
  if (task)
116
105
  task.output = message;
117
106
  throw error;
@@ -131,7 +120,7 @@ export default class Sync extends Command {
131
120
  task.output = plan.action === 'create' ? 'Created on the API' : 'Linked to the API';
132
121
  }
133
122
  catch (error) {
134
- const message = `Failed to sync project "${plan.project.name}": ${error.message}`;
123
+ const message = `Failed to push project "${plan.project.name}": ${error.message}`;
135
124
  if (task)
136
125
  task.output = message;
137
126
  throw error;
@@ -183,31 +172,6 @@ export default class Sync extends Command {
183
172
  }
184
173
  return { action: 'create', project };
185
174
  }
186
- pullProject(apiProject, task) {
187
- // Reuse a local project already linked to this API project instead of always minting a
188
- // new one: otherwise every sync run that can't "claim" an existing link (e.g. after the
189
- // local config was reset or desynced from the API) creates yet another duplicate entry.
190
- const existing = configManager.findProjectByApiId(apiProject.id);
191
- const environments = { ...existing?.environments };
192
- for (const env of apiProject.environments) {
193
- environments[env.name] = {
194
- ...environments[env.name],
195
- addedAt: environments[env.name]?.addedAt ?? env.createdAt,
196
- apiEnvironmentId: env.id,
197
- name: env.name,
198
- url: env.url,
199
- };
200
- }
201
- configManager.setProject(existing?.id ?? configManager.createProjectId(apiProject.name), {
202
- addedAt: existing?.addedAt ?? apiProject.createdAt,
203
- apiProjectId: apiProject.id,
204
- environments,
205
- name: apiProject.name,
206
- });
207
- const envCount = apiProject.environments.length;
208
- if (task)
209
- task.output = `Pulled with ${envCount} environment${envCount === 1 ? '' : 's'}`;
210
- }
211
175
  async pushCredentials(api, apiProjectId, apiEnvironmentId, env) {
212
176
  if (!env.token)
213
177
  return;
@@ -32,7 +32,7 @@ export default class Publish extends Command {
32
32
  this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
33
33
  }
34
34
  if (!project.apiProjectId) {
35
- this.error(`Project "${project.name}" is not linked to your Loopress account yet. Run \`lps project sync\` first.`);
35
+ this.error(`Project "${project.name}" is not linked to your Loopress account yet. Run \`lps project push\` first.`);
36
36
  }
37
37
  const path = args.path ?? join(localConfig.rootDir ?? '.', localConfig.snippetsDir ?? 'snippets');
38
38
  this.log(`Publishing snippets from ${path}`);
@@ -24,10 +24,10 @@ export class ProjectConfigManager {
24
24
  mkdirSync(dir, { recursive: true });
25
25
  }
26
26
  }
27
- // Looks up a local project already linked to a given API project, regardless of whether
28
- // it was "claimed" in the current sync run. Used by `project sync` to avoid minting a new
29
- // local project every time it pulls an API project whose local link was lost (e.g. after
30
- // a reset or partial config corruption), which otherwise accumulates duplicate entries.
27
+ // Looks up a local project already linked to a given API project. Used by `project pull` to
28
+ // avoid minting a new local project every time it pulls an API project whose local link was
29
+ // lost (e.g. after a reset or partial config corruption), which otherwise accumulates
30
+ // duplicate entries.
31
31
  findProjectByApiId(apiProjectId) {
32
32
  const config = this.readConfig();
33
33
  for (const [id, project] of Object.entries(config.projects)) {
package/dist/help.js CHANGED
@@ -7,7 +7,7 @@ const BANNER = `
7
7
  @@@@ ++++ ▗▖ ▗▄▖ ▗▄▖ ▗▄▄▖ ▗▄▄▖ ▗▄▄▄▖ ▗▄▄▖ ▗▄▄▖
8
8
  @@@@ ++++ ▐▌ ▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌ ▐▌
9
9
  @@@@ ++++ ▐▌ ▐▌ ▐▌▐▌ ▐▌▐▛▀▘ ▐▛▀▚▖▐▛▀▀▘ ▝▀▚▖ ▝▀▚▖
10
- @@@@ ++++ ▐▙▄▄▖▝▚▄▞▘▝▚▄▞▘▐▌ ▐▌ ▐▌▐▙▄▄▖▗▄▄▞▘▗▄▄▞▘ (BETA)
10
+ @@@@ ++++ ▐▙▄▄▖▝▚▄▞▘▝▚▄▞▘▐▌ ▐▌ ▐▌▐▙▄▄▖▗▄▄▞▘▗▄▄▞▘ (ALPHA)
11
11
  @@@@ ++++
12
12
  @@@@ ++++
13
13
  @@@@@@@@@@@*++
@@ -1,4 +1,4 @@
1
- import { isTelemetryDisabled, resolveEnvironment, runtimeContext, SENTRY_DSN } from '../lib/sentry.js';
1
+ import { isTelemetryDisabled, redactArgv, resolveEnvironment, runtimeContext, SENTRY_DSN, } from '../lib/sentry.js';
2
2
  // oclif has no `command_error` hook (checked @oclif/core@4.11.11's hooks.d.ts). `finally`
3
3
  // is the closest equivalent: it always runs at the end of the CLI lifecycle and carries
4
4
  // the error, if any, so it's where we report crashes before the process exits.
@@ -15,10 +15,15 @@ const hook = async function (options) {
15
15
  dsn: SENTRY_DSN,
16
16
  environment: resolveEnvironment(),
17
17
  release: this.config.version,
18
+ // Node defaults `server_name` to os.hostname(), which is often the user's real name
19
+ // (e.g. "jane-doe-macbook-pro"). sendDefaultPii covers IP addresses and similar, off by
20
+ // default but set explicitly since this reports from users' own machines.
21
+ sendDefaultPii: false,
22
+ serverName: 'loopress',
18
23
  });
19
24
  Sentry.captureException(options.error, {
20
25
  contexts: { runtime: runtimeContext() },
21
- extra: { argv: options.argv },
26
+ extra: { argv: redactArgv(options.argv) },
22
27
  tags: { command: options.id },
23
28
  });
24
29
  await Sentry.flush(2000);
@@ -13,6 +13,7 @@ export declare abstract class LoopressCommand extends Command {
13
13
  protected get rootDir(): string;
14
14
  protected get wp(): WpClient;
15
15
  init(): Promise<void>;
16
+ protected resolveAcfPath(override?: string): string;
16
17
  protected resolveSnippetsPath(override?: string): string;
17
18
  private resolveEnvironment;
18
19
  }
package/dist/lib/base.js CHANGED
@@ -35,6 +35,11 @@ export class LoopressCommand extends Command {
35
35
  this.localConfig = await readLocalConfig();
36
36
  this.siteConfig = this.resolveEnvironment();
37
37
  }
38
+ resolveAcfPath(override) {
39
+ if (override)
40
+ return override;
41
+ return join(this.rootDir, this.localConfig.acfDir ?? 'acf');
42
+ }
38
43
  resolveSnippetsPath(override) {
39
44
  if (override)
40
45
  return override;
@@ -5,3 +5,4 @@ export declare function runtimeContext(): {
5
5
  node: string;
6
6
  os: string;
7
7
  };
8
+ export declare function redactArgv(argv: string[]): string[];
@@ -20,3 +20,14 @@ export function runtimeContext() {
20
20
  os: `${platform()} ${release()}`,
21
21
  };
22
22
  }
23
+ // Positional args and flag values can carry WordPress URLs, usernames, application passwords,
24
+ // or tokens (e.g. `lps login --token xxx`, `lps project config <url>`). Only flag names are
25
+ // safe to report, they help tell which code path crashed without leaking what was passed to it.
26
+ export function redactArgv(argv) {
27
+ return argv.map((arg) => {
28
+ if (!arg.startsWith('-'))
29
+ return '[REDACTED]';
30
+ const eqIndex = arg.indexOf('=');
31
+ return eqIndex === -1 ? arg : arg.slice(0, eqIndex);
32
+ });
33
+ }
@@ -30,7 +30,7 @@ export interface ProjectConfig {
30
30
  */
31
31
  addedAt: string;
32
32
  /**
33
- * Id of the matching project on the Loopress API, once synced via `lps project sync`.
33
+ * Id of the matching project on the Loopress API, once pushed via `lps project push`.
34
34
  */
35
35
  apiProjectId?: string;
36
36
  /**
@@ -50,7 +50,7 @@ export interface EnvironmentConfig {
50
50
  */
51
51
  addedAt: string;
52
52
  /**
53
- * Id of the matching environment on the Loopress API, once synced via `lps project sync`.
53
+ * Id of the matching environment on the Loopress API, once pushed via `lps project push`.
54
54
  */
55
55
  apiEnvironmentId?: string;
56
56
  /**
@@ -11,6 +11,10 @@ export interface LoopressProjectConfiguration {
11
11
  * Directory where code snippets are read from and written to, relative to rootDir.
12
12
  */
13
13
  snippetsDir?: string;
14
+ /**
15
+ * Directory where ACF field groups, post types, taxonomies and options pages are read from and written to, relative to rootDir.
16
+ */
17
+ acfDir?: string;
14
18
  /**
15
19
  * Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
16
20
  */
@@ -0,0 +1,4 @@
1
+ export declare const ACF_OBJECT_TYPES: readonly ["field-groups", "post-types", "taxonomies", "options-pages"];
2
+ export type AcfObjectType = (typeof ACF_OBJECT_TYPES)[number];
3
+ export declare function acfEndpoint(type: AcfObjectType): string;
4
+ export declare function getAcfKey(data: Record<string, unknown>): null | string;
@@ -0,0 +1,12 @@
1
+ export const ACF_OBJECT_TYPES = ['field-groups', 'post-types', 'taxonomies', 'options-pages'];
2
+ export function acfEndpoint(type) {
3
+ return `loopress/v1/acf/${type}`;
4
+ }
5
+ // Deliberately loose: ACF's own export JSON is large, deeply nested, and versioned by ACF
6
+ // itself (new settings appear across ACF releases). We only need `key` for filenames/identity;
7
+ // everything else round-trips through pull/push untouched, so there's no generated type for it
8
+ // (see the plan for why: shadowing ACF's own schema would be an ongoing losing battle, and the
9
+ // shared schema:types compiler options (additionalProperties: false) would be actively wrong here).
10
+ export function getAcfKey(data) {
11
+ return typeof data.key === 'string' && data.key.trim() !== '' ? data.key : null;
12
+ }
@@ -1,4 +1,12 @@
1
1
  export interface ComposerJson {
2
+ extra?: {
3
+ 'installer-paths'?: Record<string, string[]>;
4
+ };
5
+ name?: string;
6
+ repositories?: Array<{
7
+ type: string;
8
+ url: string;
9
+ }>;
2
10
  require?: Record<string, string>;
3
11
  'require-dev'?: Record<string, string>;
4
12
  }
@@ -1,7 +1,9 @@
1
1
  // Loopress must never manage itself: pulling it into loopress.json would make a later
2
2
  // `plugin push` try to reinstall it from WordPress.org, where it doesn't exist, potentially
3
- // clobbering the plugin's own directory in the process.
4
- const LOOPRESS_PLUGIN_SLUG = 'loopress';
3
+ // clobbering the plugin's own directory in the process. Checked against every slug this
4
+ // plugin has ever shipped under (pre-rename "loopress", and the current "loopress-full" /
5
+ // "loopress-light" editions), since a given site could be running any of them.
6
+ const LOOPRESS_PLUGIN_SLUGS = new Set(['loopress', 'loopress-full', 'loopress-light']);
5
7
  export function mergePluginManifest(existing, incoming) {
6
8
  const merged = { ...existing, ...incoming };
7
9
  const added = Object.keys(incoming).filter((s) => !(s in existing));
@@ -25,7 +27,7 @@ export function parseInstalledPlugins(raw) {
25
27
  slug: slugFromPluginFile(item.plugin),
26
28
  version: item.version,
27
29
  }))
28
- .filter((plugin) => plugin.slug !== LOOPRESS_PLUGIN_SLUG);
30
+ .filter((plugin) => !LOOPRESS_PLUGIN_SLUGS.has(plugin.slug));
29
31
  }
30
32
  export function diffPlugins(manifest, installed) {
31
33
  const installedMap = new Map(installed.map((p) => [p.slug, p]));