@loopress/cli 0.13.0 → 0.14.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 (38) hide show
  1. package/README.md +62 -28
  2. package/dist/commands/init.js +5 -5
  3. package/dist/commands/plugin/add.d.ts +0 -2
  4. package/dist/commands/plugin/add.js +6 -47
  5. package/dist/commands/plugin/pull.js +4 -6
  6. package/dist/commands/plugin/push.d.ts +1 -2
  7. package/dist/commands/plugin/push.js +18 -50
  8. package/dist/commands/status.js +6 -2
  9. package/dist/commands/telemetry/disable.d.ts +6 -0
  10. package/dist/commands/telemetry/disable.js +14 -0
  11. package/dist/commands/telemetry/enable.d.ts +6 -0
  12. package/dist/commands/telemetry/enable.js +14 -0
  13. package/dist/config/auth.manager.d.ts +4 -2
  14. package/dist/config/auth.manager.js +15 -5
  15. package/dist/config/project-config.manager.d.ts +7 -2
  16. package/dist/config/project-config.manager.js +35 -6
  17. package/dist/hooks/finally.js +11 -2
  18. package/dist/hooks/init.js +8 -16
  19. package/dist/lib/local-callback-server.d.ts +3 -3
  20. package/dist/lib/local-callback-server.js +14 -4
  21. package/dist/lib/open-browser.d.ts +1 -1
  22. package/dist/lib/open-browser.js +3 -10
  23. package/dist/lib/sentry.d.ts +0 -1
  24. package/dist/lib/sentry.js +6 -8
  25. package/dist/lib/wp-authorize-flow.d.ts +15 -3
  26. package/dist/lib/wp-authorize-flow.js +31 -24
  27. package/dist/lib/wp-client.d.ts +1 -1
  28. package/dist/lib/wp-client.js +1 -1
  29. package/dist/types/config.d.ts +1 -1
  30. package/dist/types/global-config.generated.d.ts +11 -1
  31. package/dist/types/plugin.d.ts +4 -5
  32. package/dist/types/project-config.generated.d.ts +1 -4
  33. package/dist/utils/plugins.d.ts +3 -7
  34. package/dist/utils/plugins.js +27 -13
  35. package/oclif.manifest.json +133 -90
  36. package/package.json +6 -2
  37. package/schemas/global-config.schema.json +17 -1
  38. package/schemas/project-config.schema.json +3 -4
@@ -1,7 +1,7 @@
1
1
  import { EnvironmentConfig, LoopressConfig, ProjectConfig } from '../types/config.js';
2
2
  export declare class ProjectConfigManager {
3
- private readonly homeDir;
4
- constructor(homeDir?: string);
3
+ private configDir?;
4
+ constructor(configDir?: string | undefined);
5
5
  createProjectId(name: string): string;
6
6
  ensureConfigDir(): void;
7
7
  findProjectByApiId(apiProjectId: string): null | (ProjectConfig & {
@@ -14,6 +14,7 @@ export declare class ProjectConfigManager {
14
14
  });
15
15
  getEnvironment(projectId: string, envName: string): EnvironmentConfig | null;
16
16
  getProject(id: string): null | ProjectConfig;
17
+ isTelemetryDisabled(): boolean;
17
18
  listEnvironments(projectId: string): Array<EnvironmentConfig & {
18
19
  isCurrent: boolean;
19
20
  }>;
@@ -24,15 +25,19 @@ export declare class ProjectConfigManager {
24
25
  readConfig(): LoopressConfig;
25
26
  removeEnvironment(projectId: string, envName: string): void;
26
27
  removeProject(id: string): void;
28
+ setConfigDir(configDir: string): void;
27
29
  setCurrent(projectId: string, envName: string): void;
28
30
  setEnvironment(projectId: string, envName: string, env: EnvironmentConfig): void;
29
31
  setEnvironmentApiId(projectId: string, envName: string, apiEnvironmentId: string): void;
30
32
  setProject(id: string, project: ProjectConfig): void;
31
33
  setProjectApiId(id: string, apiProjectId: string): void;
34
+ setTelemetryDisabled(disabled: boolean): void;
32
35
  writeConfig(config: LoopressConfig): void;
33
36
  private isProjectConfig;
37
+ private requireConfigDir;
34
38
  private sanitizeConfig;
35
39
  private sanitizeCurrentProject;
36
40
  private sanitizeProjects;
41
+ private sanitizeTelemetry;
37
42
  }
38
43
  export declare const configManager: ProjectConfigManager;
@@ -1,12 +1,11 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
2
  import { join } from 'node:path';
4
3
  import slugify from 'slugify';
5
4
  import { readJsonFile, writeJsonFileAtomic } from './json-file.js';
6
5
  export class ProjectConfigManager {
7
- homeDir;
8
- constructor(homeDir = homedir()) {
9
- this.homeDir = homeDir;
6
+ configDir;
7
+ constructor(configDir) {
8
+ this.configDir = configDir;
10
9
  }
11
10
  createProjectId(name) {
12
11
  const config = this.readConfig();
@@ -20,7 +19,7 @@ export class ProjectConfigManager {
20
19
  return id;
21
20
  }
22
21
  ensureConfigDir() {
23
- const dir = join(this.homeDir, '.loopress');
22
+ const dir = this.requireConfigDir();
24
23
  if (!existsSync(dir)) {
25
24
  mkdirSync(dir, { recursive: true });
26
25
  }
@@ -38,7 +37,7 @@ export class ProjectConfigManager {
38
37
  return null;
39
38
  }
40
39
  getConfigFilePath() {
41
- return join(this.homeDir, '.loopress', 'config.json');
40
+ return join(this.requireConfigDir(), 'config.json');
42
41
  }
43
42
  getCurrentEnv() {
44
43
  const config = this.readConfig();
@@ -68,6 +67,9 @@ export class ProjectConfigManager {
68
67
  const config = this.readConfig();
69
68
  return config.projects[id] ?? null;
70
69
  }
70
+ isTelemetryDisabled() {
71
+ return this.readConfig().telemetry?.disabled ?? false;
72
+ }
71
73
  listEnvironments(projectId) {
72
74
  const config = this.readConfig();
73
75
  const project = config.projects[projectId];
@@ -116,6 +118,12 @@ export class ProjectConfigManager {
116
118
  }
117
119
  this.writeConfig(config);
118
120
  }
121
+ // Repointed by the `init` hook to oclif's native configDir once the real CLI Config is
122
+ // available. The constructor default only serves contexts that bypass the oclif lifecycle
123
+ // (e.g. commands instantiated directly in unit tests).
124
+ setConfigDir(configDir) {
125
+ this.configDir = configDir;
126
+ }
119
127
  setCurrent(projectId, envName) {
120
128
  const config = this.readConfig();
121
129
  if (!config.projects[projectId])
@@ -159,6 +167,11 @@ export class ProjectConfigManager {
159
167
  project.apiProjectId = apiProjectId;
160
168
  this.writeConfig(config);
161
169
  }
170
+ setTelemetryDisabled(disabled) {
171
+ const config = this.readConfig();
172
+ config.telemetry = { disabled };
173
+ this.writeConfig(config);
174
+ }
162
175
  writeConfig(config) {
163
176
  writeJsonFileAtomic(this.getConfigFilePath(), config);
164
177
  }
@@ -168,13 +181,23 @@ export class ProjectConfigManager {
168
181
  const candidate = value;
169
182
  return typeof candidate.name === 'string' && typeof candidate.environments === 'object' && candidate.environments !== null;
170
183
  }
184
+ // Real CLI runs get this from the `init` hook (src/hooks/init.ts) before any command runs.
185
+ // Throwing when it's unset (rather than falling back to a hardcoded path) surfaces tests that
186
+ // forgot to configure the manager instead of silently touching some default location.
187
+ requireConfigDir() {
188
+ if (!this.configDir)
189
+ throw new Error('ProjectConfigManager used before setConfigDir() was called');
190
+ return this.configDir;
191
+ }
171
192
  sanitizeConfig(value) {
172
193
  if (typeof value !== 'object' || value === null)
173
194
  return { currentProject: null, projects: {} };
174
195
  const candidate = value;
196
+ const telemetry = this.sanitizeTelemetry(candidate.telemetry);
175
197
  return {
176
198
  currentProject: this.sanitizeCurrentProject(candidate.currentProject),
177
199
  projects: this.sanitizeProjects(candidate.projects),
200
+ ...(telemetry && { telemetry }),
178
201
  };
179
202
  }
180
203
  sanitizeCurrentProject(value) {
@@ -193,5 +216,11 @@ export class ProjectConfigManager {
193
216
  }
194
217
  return projects;
195
218
  }
219
+ sanitizeTelemetry(value) {
220
+ if (typeof value !== 'object' || value === null)
221
+ return undefined;
222
+ const candidate = value;
223
+ return typeof candidate.disabled === 'boolean' ? { disabled: candidate.disabled } : undefined;
224
+ }
196
225
  }
197
226
  export const configManager = new ProjectConfigManager();
@@ -1,12 +1,21 @@
1
- import * as Sentry from '@sentry/node';
2
- import { isTelemetryDisabled, runtimeContext } from '../lib/sentry.js';
1
+ import { isTelemetryDisabled, resolveEnvironment, runtimeContext, SENTRY_DSN } from '../lib/sentry.js';
3
2
  // oclif has no `command_error` hook (checked @oclif/core@4.11.11's hooks.d.ts). `finally`
4
3
  // is the closest equivalent: it always runs at the end of the CLI lifecycle and carries
5
4
  // the error, if any, so it's where we report crashes before the process exits.
5
+ //
6
+ // @sentry/node is imported dynamically here, only when there's actually an error to report.
7
+ // It's a heavy module (@opentelemetry deps, import-in-the-middle instrumentation), so loading
8
+ // it eagerly on every command would tax the common case where commands succeed.
6
9
  const hook = async function (options) {
7
10
  if (!options.error || isTelemetryDisabled())
8
11
  return;
9
12
  try {
13
+ const Sentry = await import('@sentry/node');
14
+ Sentry.init({
15
+ dsn: SENTRY_DSN,
16
+ environment: resolveEnvironment(),
17
+ release: this.config.version,
18
+ });
10
19
  Sentry.captureException(options.error, {
11
20
  contexts: { runtime: runtimeContext() },
12
21
  extra: { argv: options.argv },
@@ -1,18 +1,10 @@
1
- import * as Sentry from '@sentry/node';
2
- import { consumeErrorReportingFlag, isTelemetryDisabled, resolveEnvironment, SENTRY_DSN } from '../lib/sentry.js';
3
- const hook = async function (options) {
4
- consumeErrorReportingFlag(options.argv);
5
- if (isTelemetryDisabled())
6
- return;
7
- try {
8
- Sentry.init({
9
- dsn: SENTRY_DSN,
10
- environment: resolveEnvironment(),
11
- release: this.config.version,
12
- });
13
- }
14
- catch (error) {
15
- this.debug('Failed to initialize Sentry: %O', error);
16
- }
1
+ import { authManager } from '../config/auth.manager.js';
2
+ import { configManager } from '../config/project-config.manager.js';
3
+ // configManager/authManager start unconfigured and throw if used before a directory is set.
4
+ // This hook runs before any command and points them at oclif's native, per-platform config/data
5
+ // directories as soon as the real CLI Config is available.
6
+ const hook = async function ({ config }) {
7
+ configManager.setConfigDir(config.configDir);
8
+ authManager.setDataDir(config.dataDir);
17
9
  };
18
10
  export default hook;
@@ -1,12 +1,12 @@
1
1
  export type CallbackHelpers<T> = {
2
+ body: Record<string, string>;
2
3
  rejectWithPage: (page: string, error: Error) => void;
3
4
  resolveWithPage: (page: string, value: T) => void;
4
5
  respondBadRequest: (message: string) => void;
5
6
  };
6
7
  /**
7
- * Both `login` and `authorizeWithBrowser` send the user to a URL in their browser and need to
8
- * catch the resulting redirect on a short-lived local server; this factors out the server setup,
9
- * timeout, and browser-opening boilerplate they'd otherwise duplicate.
8
+ * Sends the user to a URL in their browser and catches the resulting redirect on a short-lived
9
+ * local server; this factors out the server setup, timeout, and browser-opening boilerplate.
10
10
  */
11
11
  export declare function waitForLocalCallback<T>(options: {
12
12
  buildUrl: (callbackBaseUrl: string) => string;
@@ -1,9 +1,17 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { openBrowser } from './open-browser.js';
3
+ async function readBody(req) {
4
+ const chunks = [];
5
+ for await (const chunk of req)
6
+ chunks.push(chunk);
7
+ return Buffer.concat(chunks).toString('utf8');
8
+ }
9
+ function parseFormData(body) {
10
+ return Object.fromEntries(new URLSearchParams(body));
11
+ }
3
12
  /**
4
- * Both `login` and `authorizeWithBrowser` send the user to a URL in their browser and need to
5
- * catch the resulting redirect on a short-lived local server; this factors out the server setup,
6
- * timeout, and browser-opening boilerplate they'd otherwise duplicate.
13
+ * Sends the user to a URL in their browser and catches the resulting redirect on a short-lived
14
+ * local server; this factors out the server setup, timeout, and browser-opening boilerplate.
7
15
  */
8
16
  export function waitForLocalCallback(options) {
9
17
  const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
@@ -15,9 +23,10 @@ export function waitForLocalCallback(options) {
15
23
  server.close();
16
24
  settle();
17
25
  }
18
- const server = createServer((req, res) => {
26
+ const server = createServer(async (req, res) => {
19
27
  try {
20
28
  const url = new URL(req.url ?? '/', 'http://localhost');
29
+ const body = req.method === 'POST' ? parseFormData(await readBody(req)) : {};
21
30
  options.handleRequest(url, {
22
31
  rejectWithPage: (page, error) => finish(res, page, () => reject(error)),
23
32
  resolveWithPage: (page, value) => finish(res, page, () => resolve(value)),
@@ -25,6 +34,7 @@ export function waitForLocalCallback(options) {
25
34
  res.writeHead(400, { 'Content-Type': 'text/plain' });
26
35
  res.end(message);
27
36
  },
37
+ body,
28
38
  });
29
39
  }
30
40
  catch (error) {
@@ -1,2 +1,2 @@
1
- /** Opens `url` in the user's default browser, best-effort (no-op on unknown platforms). */
1
+ /** Opens `url` in the user's default browser, best-effort. */
2
2
  export declare function openBrowser(url: string): void;
@@ -1,12 +1,5 @@
1
- import { execFile } from 'node:child_process';
2
- /** Opens `url` in the user's default browser, best-effort (no-op on unknown platforms). */
1
+ import open from 'open';
2
+ /** Opens `url` in the user's default browser, best-effort. */
3
3
  export function openBrowser(url) {
4
- const commands = {
5
- darwin: ['open', [url]],
6
- linux: ['xdg-open', [url]],
7
- win32: ['cmd', ['/c', 'start', '', url]],
8
- };
9
- const command = commands[process.platform];
10
- if (command)
11
- execFile(...command);
4
+ open(url).catch(() => { });
12
5
  }
@@ -1,5 +1,4 @@
1
1
  export declare const SENTRY_DSN = "https://a08dd56bfffc2a45d5b8f665e4cb8b7d@o4511586904309760.ingest.de.sentry.io/4511673275973712";
2
- export declare function consumeErrorReportingFlag(argv: string[]): void;
3
2
  export declare function isTelemetryDisabled(): boolean;
4
3
  export declare function resolveEnvironment(): string;
5
4
  export declare function runtimeContext(): {
@@ -1,15 +1,13 @@
1
1
  import { platform, release } from 'node:os';
2
+ import { configManager } from '../config/project-config.manager.js';
2
3
  // DSNs are write-only and safe to embed in a distributed CLI, see https://docs.sentry.io/product/security/#can-i-make-my-sentry-dsn-private
3
4
  export const SENTRY_DSN = 'https://a08dd56bfffc2a45d5b8f665e4cb8b7d@o4511586904309760.ingest.de.sentry.io/4511673275973712';
4
- export function consumeErrorReportingFlag(argv) {
5
- const index = argv.indexOf('--no-error-reporting');
6
- if (index === -1)
7
- return;
8
- argv.splice(index, 1);
9
- process.env.LOOPRESS_TELEMETRY_DISABLED = '1';
10
- }
5
+ // The env var takes priority so CI/ephemeral environments can opt out for a single run
6
+ // without touching the persistent preference in the global config.json.
11
7
  export function isTelemetryDisabled() {
12
- return process.env.LOOPRESS_TELEMETRY_DISABLED === '1';
8
+ if (process.env.LOOPRESS_TELEMETRY_DISABLED === '1')
9
+ return true;
10
+ return configManager.isTelemetryDisabled();
13
11
  }
14
12
  export function resolveEnvironment() {
15
13
  if (process.env.SENTRY_ENVIRONMENT)
@@ -3,8 +3,20 @@ export type AuthorizeResult = {
3
3
  userLogin: string;
4
4
  };
5
5
  /**
6
- * Runs WordPress's native "authorize application" flow: opens a local callback server,
7
- * sends the user to `<siteUrl>/wp-admin/authorize-application.php`, and resolves with the
8
- * generated Application Password once WordPress redirects back.
6
+ * Relays the authorization through the Loopress API so that WordPress's
7
+ * `success_url` / `reject_url` can be valid HTTPS URLs. The API receives the
8
+ * redirect from WordPress and forwards the credentials back to a local callback
9
+ * server via a form POST (keeping them out of the browser's address bar).
10
+ *
11
+ * Flow:
12
+ * 1. Start a local HTTP server on `127.0.0.1`.
13
+ * 2. Open the browser to `https://api.loopress.dev/auth/wp-authorize` with the local
14
+ * callback URL and the target WordPress site as query params.
15
+ * 3. That page redirects the user to WordPress's authorize-application.php,
16
+ * passing the API's callback endpoint as `success_url` (HTTPS).
17
+ * 4. After the user approves, WordPress redirects to the API callback.
18
+ * 5. The API callback returns an HTML page that POSTs a form with the
19
+ * Application Password to the local callback server.
20
+ * 6. The local server extracts the credentials and resolves the promise.
9
21
  */
10
22
  export declare function authorizeWithBrowser(siteUrl: string, log: (message: string) => void): Promise<AuthorizeResult>;
@@ -1,53 +1,60 @@
1
- import { randomBytes } from 'node:crypto';
2
1
  import { renderResultPage, waitForLocalCallback } from './local-callback-server.js';
3
- const APP_NAME = 'Loopress';
4
2
  /**
5
- * Runs WordPress's native "authorize application" flow: opens a local callback server,
6
- * sends the user to `<siteUrl>/wp-admin/authorize-application.php`, and resolves with the
7
- * generated Application Password once WordPress redirects back.
3
+ * Relays the authorization through the Loopress API so that WordPress's
4
+ * `success_url` / `reject_url` can be valid HTTPS URLs. The API receives the
5
+ * redirect from WordPress and forwards the credentials back to a local callback
6
+ * server via a form POST (keeping them out of the browser's address bar).
7
+ *
8
+ * Flow:
9
+ * 1. Start a local HTTP server on `127.0.0.1`.
10
+ * 2. Open the browser to `https://api.loopress.dev/auth/wp-authorize` with the local
11
+ * callback URL and the target WordPress site as query params.
12
+ * 3. That page redirects the user to WordPress's authorize-application.php,
13
+ * passing the API's callback endpoint as `success_url` (HTTPS).
14
+ * 4. After the user approves, WordPress redirects to the API callback.
15
+ * 5. The API callback returns an HTML page that POSTs a form with the
16
+ * Application Password to the local callback server.
17
+ * 6. The local server extracts the credentials and resolves the promise.
8
18
  */
9
19
  export function authorizeWithBrowser(siteUrl, log) {
10
- const state = randomBytes(32).toString('hex');
11
20
  return waitForLocalCallback({
12
21
  buildUrl(callbackBaseUrl) {
13
- const successUrl = `${callbackBaseUrl}/callback?state=${state}`;
14
- const rejectUrl = `${callbackBaseUrl}/reject?state=${state}`;
15
- return (`${siteUrl}/wp-admin/authorize-application.php?app_name=${encodeURIComponent(APP_NAME)}` +
16
- `&success_url=${encodeURIComponent(successUrl)}&reject_url=${encodeURIComponent(rejectUrl)}`);
22
+ const relayUrl = 'https://api.loopress.dev/auth/wp-authorize';
23
+ const params = new URLSearchParams({
24
+ callbackUrl: callbackBaseUrl,
25
+ wpUrl: siteUrl,
26
+ });
27
+ return `${relayUrl}?${params}`;
17
28
  },
18
- handleRequest(url, { rejectWithPage, resolveWithPage, respondBadRequest }) {
19
- if (url.searchParams.get('state') !== state) {
20
- respondBadRequest('Invalid or missing state');
29
+ handleRequest(url, { resolveWithPage, rejectWithPage, respondBadRequest, body }) {
30
+ if (url.searchParams.has('cancelled') || body.cancelled) {
31
+ rejectWithPage(REJECTED_PAGE, new Error('Authorization rejected in WordPress.'));
21
32
  return;
22
33
  }
23
- if (url.pathname === '/reject') {
24
- rejectWithPage(REJECTED_PAGE, new Error('Authorization rejected in WordPress. You can enter credentials manually instead.'));
25
- return;
26
- }
27
- const userLogin = url.searchParams.get('user_login');
28
- const password = url.searchParams.get('password');
29
- if (!userLogin || !password) {
30
- respondBadRequest('Missing user_login or password');
34
+ const password = body.password || url.searchParams.get('password') || '';
35
+ const userLogin = body.user_login || url.searchParams.get('user_login') || '';
36
+ if (!password || !userLogin) {
37
+ respondBadRequest('Missing password or user_login');
31
38
  return;
32
39
  }
33
40
  resolveWithPage(SUCCESS_PAGE, { password, userLogin });
34
41
  },
35
42
  log,
36
43
  openingMessage: 'Opening WordPress in your browser to authorize Loopress...',
37
- timeoutMessage: 'Authorization timed out after 5 minutes. You can enter credentials manually instead.',
44
+ timeoutMessage: 'Authorization timed out after 5 minutes.',
38
45
  });
39
46
  }
40
47
  const SUCCESS_PAGE = renderResultPage({
41
48
  background: '#f0fdf4',
42
49
  heading: 'Authorization successful!',
43
50
  headingColor: '#15803d',
44
- icon: '',
51
+ icon: '&#10003;',
45
52
  tabTitle: 'Authorized',
46
53
  });
47
54
  const REJECTED_PAGE = renderResultPage({
48
55
  background: '#fef2f2',
49
56
  heading: 'Authorization rejected',
50
57
  headingColor: '#b91c1c',
51
- icon: '',
58
+ icon: '&#10007;',
52
59
  tabTitle: 'Authorization rejected',
53
60
  });
@@ -1,7 +1,7 @@
1
1
  export declare const REQUEST_TIMEOUT_MS = 30000;
2
2
  /**
3
3
  * HTTP client for a WordPress site's REST API.
4
- * Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/plugins`.
4
+ * Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/snippets` or `wp/v2/plugins`.
5
5
  */
6
6
  export declare class WpClient {
7
7
  private readonly siteUrl;
@@ -2,7 +2,7 @@ import got from 'got';
2
2
  export const REQUEST_TIMEOUT_MS = 30_000;
3
3
  /**
4
4
  * HTTP client for a WordPress site's REST API.
5
- * Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/plugins`.
5
+ * Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/snippets` or `wp/v2/plugins`.
6
6
  */
7
7
  export class WpClient {
8
8
  siteUrl;
@@ -1 +1 @@
1
- export type { CurrentProjectPointer, EnvironmentConfig, LoopressGlobalConfiguration as LoopressConfig, ProjectConfig, } from './global-config.generated.js';
1
+ export type { CurrentProjectPointer, EnvironmentConfig, LoopressGlobalConfiguration as LoopressConfig, ProjectConfig, TelemetryConfig, } from './global-config.generated.js';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Global CLI state stored at ~/.loopress/config.json: known projects, their environments, and which one is currently active.
2
+ * Global CLI state stored at $XDG_CONFIG_HOME/loopress/config.json (or ~/.config/loopress/config.json): known projects, their environments, and which one is currently active.
3
3
  */
4
4
  export interface LoopressGlobalConfiguration {
5
5
  /**
@@ -12,6 +12,7 @@ export interface LoopressGlobalConfiguration {
12
12
  projects: {
13
13
  [k: string]: ProjectConfig;
14
14
  };
15
+ telemetry?: TelemetryConfig;
15
16
  }
16
17
  export interface CurrentProjectPointer {
17
18
  /**
@@ -65,3 +66,12 @@ export interface EnvironmentConfig {
65
66
  */
66
67
  url: string;
67
68
  }
69
+ /**
70
+ * User preferences for error reporting, set via `lps telemetry disable`/`lps telemetry enable`.
71
+ */
72
+ export interface TelemetryConfig {
73
+ /**
74
+ * Whether error reporting to Sentry is disabled.
75
+ */
76
+ disabled: boolean;
77
+ }
@@ -5,11 +5,10 @@ export interface InstalledPlugin {
5
5
  slug: string;
6
6
  version: string;
7
7
  }
8
- export interface InstallResult {
9
- message: string;
8
+ export interface WpNativePlugin {
9
+ name: string;
10
+ plugin: string;
11
+ status: 'active' | 'inactive' | 'network-active';
10
12
  version: string;
11
13
  }
12
- export interface ActivateResult {
13
- message: string;
14
- }
15
14
  export type PluginManifest = Record<string, string>;
@@ -16,12 +16,9 @@ export interface LoopressProjectConfiguration {
16
16
  */
17
17
  projectId?: string;
18
18
  /**
19
- * Pinned plugin versions. Keys are WordPress.org plugin slugs, values are version constraints.
19
+ * 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.
20
20
  */
21
21
  plugins?: {
22
- /**
23
- * Version to pin. Use an exact version (e.g. "8.9.1") or "latest".
24
- */
25
22
  [k: string]: string;
26
23
  };
27
24
  }
@@ -1,16 +1,11 @@
1
- import { InstalledPlugin, PluginManifest } from '../types/plugin.js';
1
+ import { InstalledPlugin, PluginManifest, WpNativePlugin } from '../types/plugin.js';
2
2
  export interface PluginDiff {
3
- drifted: Array<{
4
- currentVersion: string;
5
- slug: string;
6
- targetVersion: string;
7
- }>;
8
3
  toActivate: Array<{
4
+ file: string;
9
5
  slug: string;
10
6
  }>;
11
7
  toInstall: Array<{
12
8
  slug: string;
13
- targetVersion: string;
14
9
  }>;
15
10
  upToDate: string[];
16
11
  }
@@ -24,4 +19,5 @@ export interface MergeResult {
24
19
  }>;
25
20
  }
26
21
  export declare function mergePluginManifest(existing: PluginManifest, incoming: PluginManifest): MergeResult;
22
+ export declare function parseInstalledPlugins(raw: WpNativePlugin[]): InstalledPlugin[];
27
23
  export declare function diffPlugins(manifest: PluginManifest, installed: InstalledPlugin[]): PluginDiff;
@@ -1,3 +1,7 @@
1
+ // Loopress must never manage itself: pulling it into loopress.json would make a later
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';
1
5
  export function mergePluginManifest(existing, incoming) {
2
6
  const merged = { ...existing, ...incoming };
3
7
  const added = Object.keys(incoming).filter((s) => !(s in existing));
@@ -6,29 +10,39 @@ export function mergePluginManifest(existing, incoming) {
6
10
  .map((s) => ({ from: existing[s], slug: s, to: incoming[s] }));
7
11
  return { added, merged, updated };
8
12
  }
13
+ // WordPress core identifies each plugin by a `<folder>/<file>` id (or a bare `<file>` for a
14
+ // single-file plugin) with the `.php` extension stripped; the WordPress.org slug is just the
15
+ // folder name (or the bare id itself for a single-file plugin).
16
+ function slugFromPluginFile(file) {
17
+ return file.split('/')[0];
18
+ }
19
+ export function parseInstalledPlugins(raw) {
20
+ return raw
21
+ .map((item) => ({
22
+ active: item.status !== 'inactive',
23
+ file: item.plugin,
24
+ name: item.name,
25
+ slug: slugFromPluginFile(item.plugin),
26
+ version: item.version,
27
+ }))
28
+ .filter((plugin) => plugin.slug !== LOOPRESS_PLUGIN_SLUG);
29
+ }
9
30
  export function diffPlugins(manifest, installed) {
10
31
  const installedMap = new Map(installed.map((p) => [p.slug, p]));
11
32
  const toInstall = [];
12
33
  const toActivate = [];
13
- const drifted = [];
14
34
  const upToDate = [];
15
- for (const [slug, targetVersion] of Object.entries(manifest)) {
35
+ for (const slug of Object.keys(manifest)) {
16
36
  const live = installedMap.get(slug);
17
37
  if (!live) {
18
- toInstall.push({ slug, targetVersion });
19
- continue;
38
+ toInstall.push({ slug });
20
39
  }
21
- if (live.version === targetVersion) {
22
- if (live.active) {
23
- upToDate.push(slug);
24
- }
25
- else {
26
- toActivate.push({ slug });
27
- }
40
+ else if (live.active) {
41
+ upToDate.push(slug);
28
42
  }
29
43
  else {
30
- drifted.push({ currentVersion: live.version, slug, targetVersion });
44
+ toActivate.push({ file: live.file, slug });
31
45
  }
32
46
  }
33
- return { drifted, toActivate, toInstall, upToDate };
47
+ return { toActivate, toInstall, upToDate };
34
48
  }