@loopress/cli 0.8.0 → 0.9.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 (37) hide show
  1. package/README.md +40 -71
  2. package/dist/commands/composer/pull.d.ts +0 -3
  3. package/dist/commands/composer/pull.js +0 -1
  4. package/dist/commands/composer/push.d.ts +0 -3
  5. package/dist/commands/composer/push.js +0 -1
  6. package/dist/commands/plugin/add.d.ts +0 -3
  7. package/dist/commands/plugin/add.js +0 -1
  8. package/dist/commands/plugin/pull.d.ts +0 -3
  9. package/dist/commands/plugin/pull.js +0 -1
  10. package/dist/commands/plugin/push.d.ts +3 -3
  11. package/dist/commands/plugin/push.js +63 -32
  12. package/dist/commands/sentry-test.d.ts +6 -0
  13. package/dist/commands/sentry-test.js +8 -0
  14. package/dist/commands/snippet/list.d.ts +0 -3
  15. package/dist/commands/snippet/list.js +1 -6
  16. package/dist/commands/snippet/pull.d.ts +0 -3
  17. package/dist/commands/snippet/pull.js +16 -23
  18. package/dist/commands/snippet/push.d.ts +1 -3
  19. package/dist/commands/snippet/push.js +25 -24
  20. package/dist/hooks/finally.d.ts +3 -0
  21. package/dist/hooks/finally.js +21 -0
  22. package/dist/hooks/init.d.ts +3 -0
  23. package/dist/hooks/init.js +18 -0
  24. package/dist/lib/base.d.ts +0 -5
  25. package/dist/lib/base.js +1 -29
  26. package/dist/lib/sentry.d.ts +8 -0
  27. package/dist/lib/sentry.js +24 -0
  28. package/dist/types/config.d.ts +1 -19
  29. package/dist/types/global-config.generated.d.ts +59 -0
  30. package/dist/types/global-config.generated.js +2 -0
  31. package/dist/types/project-config.generated.d.ts +31 -0
  32. package/dist/types/project-config.generated.js +2 -0
  33. package/dist/types/snippet.generated.d.ts +46 -0
  34. package/dist/types/snippet.generated.js +2 -0
  35. package/dist/utils/loopress-config.d.ts +2 -7
  36. package/oclif.manifest.json +118 -292
  37. package/package.json +15 -3
@@ -1,4 +1,5 @@
1
1
  import { Args } from '@oclif/core';
2
+ import { Listr } from 'listr2';
2
3
  import { mkdir, writeFile } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import slugify from 'slugify';
@@ -43,14 +44,8 @@ export default class Pull extends LoopressCommand {
43
44
  path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
44
45
  };
45
46
  static description = 'Pull snippets from WordPress';
46
- static examples = [
47
- '$ lps snippet pull',
48
- '$ lps snippet pull --url http://example.com',
49
- '$ lps snippet pull --path ./snippets',
50
- '$ lps snippet pull --plugin wpcode',
51
- ];
47
+ static examples = ['$ lps snippet pull', '$ lps snippet pull --path ./snippets', '$ lps snippet pull --plugin wpcode'];
52
48
  static flags = {
53
- ...LoopressCommand.baseFlags,
54
49
  ...LoopressCommand.dryRunFlag,
55
50
  ...snippetPluginFlag,
56
51
  };
@@ -69,22 +64,20 @@ export default class Pull extends LoopressCommand {
69
64
  return;
70
65
  }
71
66
  await mkdir(path, { recursive: true });
72
- let count = 0;
73
- let skipped = 0;
74
- for (const snippet of snippets) {
75
- if (!snippet.name.trim()) {
76
- skipped++;
77
- continue;
78
- }
79
- const ext = EXTENSIONS[snippet.type];
80
- const slug = slugify(snippet.name, { lower: true, strict: true });
81
- const base = `${snippet.id}-${slug}`;
82
- await writeFile(join(path, `${base}.${ext}`), buildSnippetFile(snippet));
83
- await writeFile(join(path, `${base}.json`), buildMetaFile(snippet));
84
- count++;
85
- this.log(` Pulled: ${snippet.name}`);
86
- }
87
- this.log(`Pulled ${count} snippet${count === 1 ? '' : 's'} to ${path}`);
67
+ const pullable = snippets.filter((snippet) => snippet.name.trim());
68
+ const skipped = snippets.length - pullable.length;
69
+ await new Listr(pullable.map((snippet) => ({
70
+ async task(_ctx, task) {
71
+ const ext = EXTENSIONS[snippet.type];
72
+ const slug = slugify(snippet.name, { lower: true, strict: true });
73
+ const base = `${snippet.id}-${slug}`;
74
+ await writeFile(join(path, `${base}.${ext}`), buildSnippetFile(snippet));
75
+ await writeFile(join(path, `${base}.json`), buildMetaFile(snippet));
76
+ task.output = `Pulled: ${snippet.name}`;
77
+ },
78
+ title: `Pull ${snippet.name}`,
79
+ }))).run();
80
+ this.log(`Pulled ${pullable.length} snippet${pullable.length === 1 ? '' : 's'} to ${path}`);
88
81
  if (skipped > 0) {
89
82
  this.warn(`${skipped} snippet${skipped === 1 ? '' : 's'} skipped because they have no name`);
90
83
  }
@@ -8,10 +8,8 @@ export default class Push extends PushCommand {
8
8
  static flags: {
9
9
  plugin: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
10
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
- password: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
- url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
- user: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
11
  };
12
+ private failedCount;
15
13
  run(): Promise<void>;
16
14
  private ensureCanonicalFilename;
17
15
  private loadSnippets;
@@ -1,4 +1,5 @@
1
1
  import { Args } from '@oclif/core';
2
+ import { Listr } from 'listr2';
2
3
  import { readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
4
  import { basename, dirname, extname, join } from 'node:path';
4
5
  import slugify from 'slugify';
@@ -17,17 +18,12 @@ export default class Push extends PushCommand {
17
18
  path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
18
19
  };
19
20
  static description = 'Push snippets to WordPress. Local snippet files created or updated remotely are renamed on disk to the `<id>-<slug>` convention.';
20
- static examples = [
21
- '$ lps snippet push',
22
- '$ lps snippet push --url http://example.com',
23
- '$ lps snippet push --path ./snippets',
24
- '$ lps snippet push --plugin wpcode',
25
- ];
21
+ static examples = ['$ lps snippet push', '$ lps snippet push --path ./snippets', '$ lps snippet push --plugin wpcode'];
26
22
  static flags = {
27
- ...PushCommand.baseFlags,
28
23
  ...PushCommand.dryRunFlag,
29
24
  ...snippetPluginFlag,
30
25
  };
26
+ failedCount = 0;
31
27
  async run() {
32
28
  const { args, flags } = await this.parse(Push);
33
29
  const { url } = this.siteConfig;
@@ -38,14 +34,12 @@ export default class Push extends PushCommand {
38
34
  const snippets = await this.loadSnippets(path);
39
35
  this.log(`Found ${snippets.length} snippet${snippets.length === 1 ? '' : 's'} to push`);
40
36
  const adapter = getSnippetPlugin(resolvedPlugin);
41
- let failed = 0;
42
- for (const snippet of snippets) {
43
- const pushed = await this.pushSnippet(snippet, adapter);
44
- if (!pushed)
45
- failed++;
46
- }
47
- if (failed > 0) {
48
- this.error(`${failed} snippet${failed === 1 ? '' : 's'} failed to push.`);
37
+ await new Listr(snippets.map((snippet) => ({
38
+ task: async (_ctx, task) => this.pushSnippet(snippet, adapter, task),
39
+ title: `Push ${snippet.name}`,
40
+ })), { concurrent: false, exitOnError: false }).run();
41
+ if (this.failedCount > 0) {
42
+ this.error(`${this.failedCount} snippet${this.failedCount === 1 ? '' : 's'} failed to push.`);
49
43
  }
50
44
  if (this.dryRun)
51
45
  return;
@@ -85,7 +79,6 @@ export default class Push extends PushCommand {
85
79
  await rename(snippet.path, newPath);
86
80
  await writeFile(newMetaPath, JSON.stringify(meta, null, 2) + '\n');
87
81
  await rm(oldMetaPath, { force: true });
88
- this.log(` Renamed: ${snippet.path} → ${newPath}`);
89
82
  }
90
83
  async loadSnippets(path) {
91
84
  const snippets = [];
@@ -145,30 +138,38 @@ export default class Push extends PushCommand {
145
138
  }
146
139
  return snippets;
147
140
  }
148
- async pushSnippet(snippet, adapter) {
141
+ // Throwing on failure (rather than returning a boolean) is what lets Listr mark the task as
142
+ // failed (red cross) instead of completed; `exitOnError: false` on the task list still lets
143
+ // sibling snippets push regardless.
144
+ async pushSnippet(snippet, adapter, task) {
149
145
  if (this.dryRun) {
150
- this.log(`[dry-run] Would push: ${snippet.name}`);
151
- return true;
146
+ if (task)
147
+ task.output = `[dry-run] Would push: ${snippet.name}`;
148
+ return;
152
149
  }
153
150
  const endpointPath = adapter.endpointPath();
154
151
  try {
155
152
  const payload = adapter.toPayload(snippet);
156
153
  if (snippet.id) {
157
154
  await this.wp.put(`${endpointPath}/${snippet.id}`, payload);
158
- this.log(` Updated: ${snippet.name} (id: ${snippet.id})`);
159
155
  await this.ensureCanonicalFilename(snippet, snippet.id, snippet.name);
160
156
  }
161
157
  else {
162
158
  const response = await this.wp.post(endpointPath, payload);
163
159
  const created = adapter.fromRemote(response);
164
- this.log(` Created: ${snippet.name} (id: ${created.id})`);
165
160
  await this.ensureCanonicalFilename(snippet, created.id, created.name);
166
161
  }
167
- return true;
162
+ if (task)
163
+ task.output = `Pushed: ${snippet.name}`;
168
164
  }
169
165
  catch (error) {
170
- this.warn(` Failed to push ${snippet.name}: ${error.message}`);
171
- return false;
166
+ const message = `Failed to push ${snippet.name}: ${error.message}`;
167
+ if (task)
168
+ task.output = message;
169
+ else
170
+ this.warn(` ${message}`);
171
+ this.failedCount++;
172
+ throw error;
172
173
  }
173
174
  }
174
175
  }
@@ -0,0 +1,3 @@
1
+ import type { Hook } from '@oclif/core';
2
+ declare const hook: Hook.Finally;
3
+ export default hook;
@@ -0,0 +1,21 @@
1
+ import * as Sentry from '@sentry/node';
2
+ import { isTelemetryDisabled, runtimeContext } from '../lib/sentry.js';
3
+ // oclif has no `command_error` hook (checked @oclif/core@4.11.11's hooks.d.ts). `finally`
4
+ // is the closest equivalent: it always runs at the end of the CLI lifecycle and carries
5
+ // the error, if any, so it's where we report crashes before the process exits.
6
+ const hook = async function (options) {
7
+ if (!options.error || isTelemetryDisabled())
8
+ return;
9
+ try {
10
+ Sentry.captureException(options.error, {
11
+ contexts: { runtime: runtimeContext() },
12
+ extra: { argv: options.argv },
13
+ tags: { command: options.id },
14
+ });
15
+ await Sentry.flush(2000);
16
+ }
17
+ catch (error) {
18
+ this.debug('Failed to report error to Sentry: %O', error);
19
+ }
20
+ };
21
+ export default hook;
@@ -0,0 +1,3 @@
1
+ import type { Hook } from '@oclif/core';
2
+ declare const hook: Hook.Init;
3
+ export default hook;
@@ -0,0 +1,18 @@
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
+ }
17
+ };
18
+ export default hook;
@@ -3,11 +3,6 @@ import { EnvironmentConfig } from '../types/config.js';
3
3
  import { LoopressLocalConfig } from '../utils/loopress-config.js';
4
4
  import { WpClient } from './wp-client.js';
5
5
  export declare abstract class LoopressCommand extends Command {
6
- static baseFlags: {
7
- password: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
- url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
- user: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
- };
11
6
  static dryRunFlag: {
12
7
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
8
  };
package/dist/lib/base.js CHANGED
@@ -4,20 +4,6 @@ import { configManager } from '../config/project-config.manager.js';
4
4
  import { readLocalConfig } from '../utils/loopress-config.js';
5
5
  import { WpClient } from './wp-client.js';
6
6
  export class LoopressCommand extends Command {
7
- static baseFlags = {
8
- password: Flags.string({
9
- description: 'WordPress application password (overrides project config, requires --user)',
10
- helpGroup: 'GLOBAL',
11
- }),
12
- url: Flags.string({
13
- description: 'WordPress URL (overrides project config)',
14
- helpGroup: 'GLOBAL',
15
- }),
16
- user: Flags.string({
17
- description: 'WordPress username (overrides project config, requires --password)',
18
- helpGroup: 'GLOBAL',
19
- }),
20
- };
21
7
  static dryRunFlag = {
22
8
  'dry-run': Flags.boolean({ char: 'd', description: 'Show what would change without making changes' }),
23
9
  };
@@ -47,21 +33,7 @@ export class LoopressCommand extends Command {
47
33
  }));
48
34
  this.dryRun = Boolean(flags['dry-run']);
49
35
  this.localConfig = await readLocalConfig();
50
- if (Boolean(flags.user) !== Boolean(flags.password)) {
51
- this.error('--user and --password must be provided together.');
52
- }
53
- const flagToken = flags.user && flags.password ? `${flags.user}:${flags.password}` : undefined;
54
- if (flags.url) {
55
- this.siteConfig = {
56
- addedAt: new Date().toISOString(),
57
- name: 'cli-flags',
58
- token: flagToken,
59
- url: flags.url.replace(/\/+$/, ''),
60
- };
61
- return;
62
- }
63
- const env = this.resolveEnvironment();
64
- this.siteConfig = flagToken ? { ...env, token: flagToken } : env;
36
+ this.siteConfig = this.resolveEnvironment();
65
37
  }
66
38
  resolveSnippetPlugin(flag) {
67
39
  if (flag)
@@ -0,0 +1,8 @@
1
+ export declare const SENTRY_DSN = "https://a08dd56bfffc2a45d5b8f665e4cb8b7d@o4511586904309760.ingest.de.sentry.io/4511673275973712";
2
+ export declare function consumeErrorReportingFlag(argv: string[]): void;
3
+ export declare function isTelemetryDisabled(): boolean;
4
+ export declare function resolveEnvironment(): string;
5
+ export declare function runtimeContext(): {
6
+ node: string;
7
+ os: string;
8
+ };
@@ -0,0 +1,24 @@
1
+ import { platform, release } from 'node:os';
2
+ // 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
+ 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
+ }
11
+ export function isTelemetryDisabled() {
12
+ return process.env.LOOPRESS_TELEMETRY_DISABLED === '1';
13
+ }
14
+ export function resolveEnvironment() {
15
+ if (process.env.SENTRY_ENVIRONMENT)
16
+ return process.env.SENTRY_ENVIRONMENT;
17
+ return process.env.NODE_ENV === 'development' ? 'development' : 'production';
18
+ }
19
+ export function runtimeContext() {
20
+ return {
21
+ node: process.version,
22
+ os: `${platform()} ${release()}`,
23
+ };
24
+ }
@@ -1,19 +1 @@
1
- export interface EnvironmentConfig {
2
- addedAt: string;
3
- name: string;
4
- token?: string;
5
- url: string;
6
- }
7
- export interface ProjectConfig {
8
- addedAt: string;
9
- environments: Record<string, EnvironmentConfig>;
10
- name: string;
11
- }
12
- export interface CurrentProjectPointer {
13
- env: string;
14
- id: string;
15
- }
16
- export interface LoopressConfig {
17
- currentProject: CurrentProjectPointer | null;
18
- projects: Record<string, ProjectConfig>;
19
- }
1
+ export type { CurrentProjectPointer, EnvironmentConfig, LoopressGlobalConfiguration as LoopressConfig, ProjectConfig, } from './global-config.generated.js';
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Global CLI state stored at ~/.loopress/config.json: known projects, their environments, and which one is currently active.
3
+ */
4
+ export interface LoopressGlobalConfiguration {
5
+ /**
6
+ * Pointer to the currently active project and environment, or null if none is selected.
7
+ */
8
+ currentProject: null | CurrentProjectPointer;
9
+ /**
10
+ * Known projects, keyed by project id.
11
+ */
12
+ projects: {
13
+ [k: string]: ProjectConfig;
14
+ };
15
+ }
16
+ export interface CurrentProjectPointer {
17
+ /**
18
+ * Name of the currently active environment.
19
+ */
20
+ env: string;
21
+ /**
22
+ * Id of the currently active project.
23
+ */
24
+ id: string;
25
+ }
26
+ export interface ProjectConfig {
27
+ /**
28
+ * ISO timestamp of when the project was added.
29
+ */
30
+ addedAt: string;
31
+ /**
32
+ * Environments for this project, keyed by environment name.
33
+ */
34
+ environments: {
35
+ [k: string]: EnvironmentConfig;
36
+ };
37
+ /**
38
+ * Human-readable project name.
39
+ */
40
+ name: string;
41
+ }
42
+ export interface EnvironmentConfig {
43
+ /**
44
+ * ISO timestamp of when the environment was added.
45
+ */
46
+ addedAt: string;
47
+ /**
48
+ * Environment name (e.g. "production", "staging").
49
+ */
50
+ name: string;
51
+ /**
52
+ * API token used to authenticate against this environment.
53
+ */
54
+ token?: string;
55
+ /**
56
+ * Base URL of the WordPress site for this environment.
57
+ */
58
+ url: string;
59
+ }
@@ -0,0 +1,2 @@
1
+ // This file is generated from schemas/global-config.schema.json by `pnpm run schema:types`. Do not edit by hand.
2
+ export {};
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Project-level config for the Loopress CLI (loopress.json).
3
+ */
4
+ export interface LoopressProjectConfiguration {
5
+ $schema?: string;
6
+ /**
7
+ * Base directory for all Loopress paths. All other path options are resolved relative to this directory.
8
+ */
9
+ rootDir?: string;
10
+ /**
11
+ * Directory where code snippets are read from and written to, relative to rootDir.
12
+ */
13
+ snippetsDir?: string;
14
+ /**
15
+ * Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
16
+ */
17
+ projectId?: string;
18
+ /**
19
+ * WordPress snippet plugin to use for pull and push commands.
20
+ */
21
+ snippetPlugin?: 'wpcode' | 'code-snippets';
22
+ /**
23
+ * Pinned plugin versions. Keys are WordPress.org plugin slugs, values are version constraints.
24
+ */
25
+ plugins?: {
26
+ /**
27
+ * Version to pin. Use an exact version (e.g. "8.9.1") or "latest".
28
+ */
29
+ [k: string]: string;
30
+ };
31
+ }
@@ -0,0 +1,2 @@
1
+ // This file is generated from schemas/project-config.schema.json by `pnpm run schema:types`. Do not edit by hand.
2
+ export {};
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Sidecar .json file paired with a snippet's code file in the snippets directory (e.g. `123-my-snippet.php` + `123-my-snippet.json`).
3
+ */
4
+ export interface LoopressSnippetMetadata {
5
+ $schema?: string;
6
+ /**
7
+ * Remote snippet id. Omitted until the snippet has been pushed for the first time.
8
+ */
9
+ id?: number;
10
+ /**
11
+ * Snippet name/title. Defaults to the code file's name when omitted.
12
+ */
13
+ name?: string;
14
+ /**
15
+ * Snippet language. Defaults to the type inferred from the code file's extension when omitted.
16
+ */
17
+ type?: 'css' | 'html' | 'js' | 'php' | 'text';
18
+ /**
19
+ * Whether the snippet is enabled.
20
+ */
21
+ active?: boolean;
22
+ /**
23
+ * Where the snippet runs. Supported values depend on the target plugin and snippet type.
24
+ */
25
+ location?: 'admin' | 'body' | 'everywhere' | 'footer' | 'frontend' | 'header' | 'once';
26
+ /**
27
+ * Free-text note about the snippet.
28
+ */
29
+ description?: string;
30
+ /**
31
+ * Tags for organizing snippets.
32
+ */
33
+ tags?: string[];
34
+ /**
35
+ * How the snippet is inserted into the page.
36
+ */
37
+ insertMethod?: 'auto' | 'shortcode';
38
+ /**
39
+ * Execution priority. Lower runs earlier.
40
+ */
41
+ priority?: number;
42
+ /**
43
+ * Attribute names accepted by the snippet's shortcode, used when insertMethod is "shortcode".
44
+ */
45
+ shortcodeAttributes?: string[];
46
+ }
@@ -0,0 +1,2 @@
1
+ // This file is generated from schemas/snippet.schema.json by `pnpm run schema:types`. Do not edit by hand.
2
+ export {};
@@ -1,9 +1,4 @@
1
- export interface LoopressLocalConfig {
2
- plugins?: Record<string, string>;
3
- projectId?: string;
4
- rootDir?: string;
5
- snippetPlugin?: 'code-snippets' | 'wpcode';
6
- snippetsDir?: string;
7
- }
1
+ import { LoopressProjectConfiguration } from '../types/project-config.generated.js';
2
+ export type LoopressLocalConfig = LoopressProjectConfiguration;
8
3
  export declare function readLocalConfig(): Promise<LoopressLocalConfig>;
9
4
  export declare function writeLocalConfig(config: LoopressLocalConfig): Promise<void>;