@loopress/cli 0.12.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 (49) hide show
  1. package/README.md +88 -29
  2. package/dist/commands/init.js +5 -5
  3. package/dist/commands/login.d.ts +0 -1
  4. package/dist/commands/login.js +21 -90
  5. package/dist/commands/plugin/add.d.ts +0 -2
  6. package/dist/commands/plugin/add.js +6 -47
  7. package/dist/commands/plugin/pull.js +4 -6
  8. package/dist/commands/plugin/push.d.ts +1 -2
  9. package/dist/commands/plugin/push.js +18 -50
  10. package/dist/commands/project/config.d.ts +2 -0
  11. package/dist/commands/project/config.js +45 -11
  12. package/dist/commands/snippet/publish.d.ts +10 -0
  13. package/dist/commands/snippet/publish.js +74 -0
  14. package/dist/commands/snippet/push.js +5 -70
  15. package/dist/commands/status.js +6 -2
  16. package/dist/commands/telemetry/disable.d.ts +6 -0
  17. package/dist/commands/telemetry/disable.js +14 -0
  18. package/dist/commands/telemetry/enable.d.ts +6 -0
  19. package/dist/commands/telemetry/enable.js +14 -0
  20. package/dist/config/auth.manager.d.ts +4 -2
  21. package/dist/config/auth.manager.js +15 -5
  22. package/dist/config/project-config.manager.d.ts +7 -2
  23. package/dist/config/project-config.manager.js +35 -6
  24. package/dist/hooks/finally.js +11 -2
  25. package/dist/hooks/init.js +8 -16
  26. package/dist/lib/load-snippets.d.ts +2 -0
  27. package/dist/lib/load-snippets.js +84 -0
  28. package/dist/lib/local-callback-server.d.ts +25 -0
  29. package/dist/lib/local-callback-server.js +103 -0
  30. package/dist/lib/open-browser.d.ts +2 -0
  31. package/dist/lib/open-browser.js +5 -0
  32. package/dist/lib/sentry.d.ts +0 -1
  33. package/dist/lib/sentry.js +6 -8
  34. package/dist/lib/wp-authorize-flow.d.ts +22 -0
  35. package/dist/lib/wp-authorize-flow.js +60 -0
  36. package/dist/lib/wp-client.d.ts +1 -1
  37. package/dist/lib/wp-client.js +1 -1
  38. package/dist/lib/wp-site-diagnostic.d.ts +13 -0
  39. package/dist/lib/wp-site-diagnostic.js +46 -0
  40. package/dist/types/config.d.ts +1 -1
  41. package/dist/types/global-config.generated.d.ts +11 -1
  42. package/dist/types/plugin.d.ts +4 -5
  43. package/dist/types/project-config.generated.d.ts +1 -4
  44. package/dist/utils/plugins.d.ts +3 -7
  45. package/dist/utils/plugins.js +27 -13
  46. package/oclif.manifest.json +163 -90
  47. package/package.json +7 -2
  48. package/schemas/global-config.schema.json +17 -1
  49. package/schemas/project-config.schema.json +3 -4
@@ -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;
@@ -0,0 +1,2 @@
1
+ import { Snippet } from '../types/snippet.js';
2
+ export declare function loadSnippets(path: string, onSkip?: (message: string) => void): Promise<Snippet[]>;
@@ -0,0 +1,84 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { basename, extname, join } from 'node:path';
3
+ import { defaultLocationForType, parseInsertMethod, parseLocation, parseType, } from '../utils/snippet-format.js';
4
+ const TYPE_BY_EXTENSION = {
5
+ '.css': 'css',
6
+ '.html': 'html',
7
+ '.js': 'js',
8
+ '.php': 'php',
9
+ '.txt': 'text',
10
+ };
11
+ // Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
12
+ // (publishes to the Loopress api as a shared source) — both read the same local `snippets/`
13
+ // directory the same way. `onSkip` is optional so callers that don't care about per-file
14
+ // warnings (e.g. `snippet publish`) can ignore them; skipped files are simply left out either way.
15
+ export async function loadSnippets(path, onSkip) {
16
+ const snippets = [];
17
+ let files;
18
+ try {
19
+ files = await readdir(path);
20
+ }
21
+ catch (error) {
22
+ throw new Error(`Error loading snippets: ${error.message}`);
23
+ }
24
+ for (const file of files) {
25
+ const ext = extname(file);
26
+ if (!(ext in TYPE_BY_EXTENSION))
27
+ continue;
28
+ const filePath = join(path, file);
29
+ const metaPath = join(path, `${basename(file, ext)}.json`);
30
+ // One snippet's files are read in isolation: a corrupted or hand-broken sidecar (bad JSON,
31
+ // unreadable file, ...) must only skip that snippet, not abort loading every other one.
32
+ let content;
33
+ try {
34
+ content = await readFile(filePath, 'utf8');
35
+ }
36
+ catch (error) {
37
+ onSkip?.(`Skipping "${filePath}": ${error.message}`);
38
+ continue;
39
+ }
40
+ let id;
41
+ let name;
42
+ let type;
43
+ let active = false;
44
+ let tags = [];
45
+ let location = null;
46
+ let insertMethod = null;
47
+ let priority = 10;
48
+ let shortcodeAttributes = [];
49
+ try {
50
+ const metaContent = await readFile(metaPath, 'utf8');
51
+ const meta = JSON.parse(metaContent);
52
+ id = meta.id === undefined ? undefined : Number(meta.id);
53
+ name = meta.name ? String(meta.name) : undefined;
54
+ type = parseType(meta.type) ?? undefined;
55
+ active = Boolean(meta.active);
56
+ tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
57
+ location = parseLocation(meta.location);
58
+ insertMethod = parseInsertMethod(meta.insertMethod);
59
+ priority = meta.priority === undefined ? 10 : Number(meta.priority);
60
+ shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
61
+ }
62
+ catch (error) {
63
+ if (error.code !== 'ENOENT') {
64
+ onSkip?.(`Skipping "${metaPath}": ${error.message}`);
65
+ continue;
66
+ }
67
+ }
68
+ const resolvedType = type ?? (ext in TYPE_BY_EXTENSION ? TYPE_BY_EXTENSION[ext] : 'php');
69
+ snippets.push({
70
+ active,
71
+ code: content,
72
+ id,
73
+ insertMethod: insertMethod ?? 'auto',
74
+ location: location ?? defaultLocationForType(resolvedType),
75
+ name: name ?? basename(file, ext),
76
+ path: filePath,
77
+ priority,
78
+ shortcodeAttributes,
79
+ tags,
80
+ type: resolvedType,
81
+ });
82
+ }
83
+ return snippets;
84
+ }
@@ -0,0 +1,25 @@
1
+ export type CallbackHelpers<T> = {
2
+ body: Record<string, string>;
3
+ rejectWithPage: (page: string, error: Error) => void;
4
+ resolveWithPage: (page: string, value: T) => void;
5
+ respondBadRequest: (message: string) => void;
6
+ };
7
+ /**
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
+ */
11
+ export declare function waitForLocalCallback<T>(options: {
12
+ buildUrl: (callbackBaseUrl: string) => string;
13
+ handleRequest: (url: URL, helpers: CallbackHelpers<T>) => void;
14
+ log: (message: string) => void;
15
+ openingMessage: string;
16
+ timeoutMessage: string;
17
+ timeoutMs?: number;
18
+ }): Promise<T>;
19
+ export declare function renderResultPage(options: {
20
+ background: string;
21
+ heading: string;
22
+ headingColor: string;
23
+ icon: string;
24
+ tabTitle: string;
25
+ }): string;
@@ -0,0 +1,103 @@
1
+ import { createServer } from 'node:http';
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
+ }
12
+ /**
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.
15
+ */
16
+ export function waitForLocalCallback(options) {
17
+ const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
18
+ return new Promise((resolve, reject) => {
19
+ function finish(res, page, settle) {
20
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
21
+ res.end(page);
22
+ clearTimeout(timer);
23
+ server.close();
24
+ settle();
25
+ }
26
+ const server = createServer(async (req, res) => {
27
+ try {
28
+ const url = new URL(req.url ?? '/', 'http://localhost');
29
+ const body = req.method === 'POST' ? parseFormData(await readBody(req)) : {};
30
+ options.handleRequest(url, {
31
+ rejectWithPage: (page, error) => finish(res, page, () => reject(error)),
32
+ resolveWithPage: (page, value) => finish(res, page, () => resolve(value)),
33
+ respondBadRequest(message) {
34
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
35
+ res.end(message);
36
+ },
37
+ body,
38
+ });
39
+ }
40
+ catch (error) {
41
+ res.writeHead(500);
42
+ res.end('Internal error');
43
+ server.close();
44
+ reject(error);
45
+ }
46
+ });
47
+ server.on('error', (err) => {
48
+ clearTimeout(timer);
49
+ reject(err);
50
+ });
51
+ const timer = setTimeout(() => {
52
+ server.close();
53
+ reject(new Error(options.timeoutMessage));
54
+ }, timeoutMs);
55
+ server.listen(0, '127.0.0.1', () => {
56
+ const { port } = server.address();
57
+ const targetUrl = options.buildUrl(`http://localhost:${port}`);
58
+ options.log(options.openingMessage);
59
+ options.log(`\nIf it doesn't open automatically, visit:\n${targetUrl}\n`);
60
+ openBrowser(targetUrl);
61
+ });
62
+ });
63
+ }
64
+ export function renderResultPage(options) {
65
+ return `<!DOCTYPE html>
66
+ <html lang="en">
67
+ <head>
68
+ <meta charset="UTF-8">
69
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
70
+ <title>Loopress: ${options.tabTitle}</title>
71
+ <style>
72
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
73
+ body {
74
+ font-family: system-ui, -apple-system, sans-serif;
75
+ background: ${options.background};
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ min-height: 100dvh;
80
+ }
81
+ .card {
82
+ background: #fff;
83
+ border-radius: 16px;
84
+ padding: 2.5rem 3rem;
85
+ text-align: center;
86
+ box-shadow: 0 4px 32px rgba(0, 0, 0, .08);
87
+ max-width: 420px;
88
+ width: 90%;
89
+ }
90
+ .icon { font-size: 3rem; margin-bottom: 1rem; }
91
+ h1 { color: ${options.headingColor}; font-size: 1.5rem; margin-bottom: .5rem; }
92
+ p { color: #6b7280; font-size: .95rem; line-height: 1.5; }
93
+ </style>
94
+ </head>
95
+ <body>
96
+ <div class="card">
97
+ <div class="icon">${options.icon}</div>
98
+ <h1>${options.heading}</h1>
99
+ <p>You can close this tab and return to your terminal.</p>
100
+ </div>
101
+ </body>
102
+ </html>`;
103
+ }
@@ -0,0 +1,2 @@
1
+ /** Opens `url` in the user's default browser, best-effort. */
2
+ export declare function openBrowser(url: string): void;
@@ -0,0 +1,5 @@
1
+ import open from 'open';
2
+ /** Opens `url` in the user's default browser, best-effort. */
3
+ export function openBrowser(url) {
4
+ open(url).catch(() => { });
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)
@@ -0,0 +1,22 @@
1
+ export type AuthorizeResult = {
2
+ password: string;
3
+ userLogin: string;
4
+ };
5
+ /**
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.
21
+ */
22
+ export declare function authorizeWithBrowser(siteUrl: string, log: (message: string) => void): Promise<AuthorizeResult>;
@@ -0,0 +1,60 @@
1
+ import { renderResultPage, waitForLocalCallback } from './local-callback-server.js';
2
+ /**
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.
18
+ */
19
+ export function authorizeWithBrowser(siteUrl, log) {
20
+ return waitForLocalCallback({
21
+ buildUrl(callbackBaseUrl) {
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}`;
28
+ },
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.'));
32
+ return;
33
+ }
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');
38
+ return;
39
+ }
40
+ resolveWithPage(SUCCESS_PAGE, { password, userLogin });
41
+ },
42
+ log,
43
+ openingMessage: 'Opening WordPress in your browser to authorize Loopress...',
44
+ timeoutMessage: 'Authorization timed out after 5 minutes.',
45
+ });
46
+ }
47
+ const SUCCESS_PAGE = renderResultPage({
48
+ background: '#f0fdf4',
49
+ heading: 'Authorization successful!',
50
+ headingColor: '#15803d',
51
+ icon: '&#10003;',
52
+ tabTitle: 'Authorized',
53
+ });
54
+ const REJECTED_PAGE = renderResultPage({
55
+ background: '#fef2f2',
56
+ heading: 'Authorization rejected',
57
+ headingColor: '#b91c1c',
58
+ icon: '&#10007;',
59
+ tabTitle: 'Authorization rejected',
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;
@@ -0,0 +1,13 @@
1
+ export declare const REQUEST_TIMEOUT_MS = 10000;
2
+ export type DiagnosticResult = {
3
+ ok: false;
4
+ reason: string;
5
+ } | {
6
+ ok: true;
7
+ };
8
+ /**
9
+ * Pre-flight checks run before starting the browser authorization flow, so failures
10
+ * (unreachable site, blocked REST API, WordPress too old) surface as an actionable
11
+ * message instead of a confusing timeout once the browser is already open.
12
+ */
13
+ export declare function diagnoseWpSite(siteUrl: string): Promise<DiagnosticResult>;
@@ -0,0 +1,46 @@
1
+ import got from 'got';
2
+ export const REQUEST_TIMEOUT_MS = 10_000;
3
+ /**
4
+ * Pre-flight checks run before starting the browser authorization flow, so failures
5
+ * (unreachable site, blocked REST API, WordPress too old) surface as an actionable
6
+ * message instead of a confusing timeout once the browser is already open.
7
+ */
8
+ export async function diagnoseWpSite(siteUrl) {
9
+ try {
10
+ await got.get(`${siteUrl}/wp-json/`, { timeout: { request: REQUEST_TIMEOUT_MS } });
11
+ }
12
+ catch (error) {
13
+ return {
14
+ ok: false,
15
+ reason: `Could not reach the WordPress REST API at ${siteUrl}/wp-json/. The site may be unreachable, or a security plugin may be blocking it. (${describe(error)})`,
16
+ };
17
+ }
18
+ try {
19
+ const response = await got.head(`${siteUrl}/wp-admin/authorize-application.php`, {
20
+ throwHttpErrors: false,
21
+ timeout: { request: REQUEST_TIMEOUT_MS },
22
+ });
23
+ if (response.statusCode === 404) {
24
+ return {
25
+ ok: false,
26
+ reason: `The application authorization page was not found on ${siteUrl}. This WordPress site may be older than 5.6, or the feature may be disabled by a plugin.`,
27
+ };
28
+ }
29
+ if (response.statusCode >= 400) {
30
+ return {
31
+ ok: false,
32
+ reason: `The application authorization page at ${siteUrl}/wp-admin/authorize-application.php returned an error (HTTP ${response.statusCode}).`,
33
+ };
34
+ }
35
+ }
36
+ catch (error) {
37
+ return {
38
+ ok: false,
39
+ reason: `Could not reach ${siteUrl}/wp-admin/authorize-application.php. (${describe(error)})`,
40
+ };
41
+ }
42
+ return { ok: true };
43
+ }
44
+ function describe(error) {
45
+ return error instanceof Error ? error.message : String(error);
46
+ }
@@ -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
  }