@loopress/cli 0.13.0 → 0.15.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 (43) hide show
  1. package/README.md +96 -44
  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/project/pull.d.ts +8 -0
  9. package/dist/commands/project/pull.js +69 -0
  10. package/dist/commands/project/{sync.d.ts → push.d.ts} +1 -2
  11. package/dist/commands/project/{sync.js → push.js} +8 -44
  12. package/dist/commands/snippet/publish.js +1 -1
  13. package/dist/commands/status.js +6 -2
  14. package/dist/commands/telemetry/disable.d.ts +6 -0
  15. package/dist/commands/telemetry/disable.js +14 -0
  16. package/dist/commands/telemetry/enable.d.ts +6 -0
  17. package/dist/commands/telemetry/enable.js +14 -0
  18. package/dist/config/auth.manager.d.ts +4 -2
  19. package/dist/config/auth.manager.js +15 -5
  20. package/dist/config/project-config.manager.d.ts +7 -2
  21. package/dist/config/project-config.manager.js +39 -10
  22. package/dist/hooks/finally.js +17 -3
  23. package/dist/hooks/init.js +8 -16
  24. package/dist/lib/local-callback-server.d.ts +3 -3
  25. package/dist/lib/local-callback-server.js +14 -4
  26. package/dist/lib/open-browser.d.ts +1 -1
  27. package/dist/lib/open-browser.js +3 -10
  28. package/dist/lib/sentry.d.ts +1 -1
  29. package/dist/lib/sentry.js +17 -8
  30. package/dist/lib/wp-authorize-flow.d.ts +15 -3
  31. package/dist/lib/wp-authorize-flow.js +31 -24
  32. package/dist/lib/wp-client.d.ts +1 -1
  33. package/dist/lib/wp-client.js +1 -1
  34. package/dist/types/config.d.ts +1 -1
  35. package/dist/types/global-config.generated.d.ts +13 -3
  36. package/dist/types/plugin.d.ts +4 -5
  37. package/dist/types/project-config.generated.d.ts +1 -4
  38. package/dist/utils/plugins.d.ts +3 -7
  39. package/dist/utils/plugins.js +27 -13
  40. package/oclif.manifest.json +236 -169
  41. package/package.json +6 -2
  42. package/schemas/global-config.schema.json +19 -3
  43. package/schemas/project-config.schema.json +3 -4
@@ -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}`);
@@ -10,9 +10,13 @@ export default class Status extends Command {
10
10
  const localConfig = await readLocalConfig();
11
11
  if (localConfig.projectId) {
12
12
  this.reportPinnedProject(localConfig.projectId);
13
- return;
14
13
  }
15
- this.reportActiveProject();
14
+ else {
15
+ this.reportActiveProject();
16
+ }
17
+ this.log('');
18
+ this.log(`Config dir: ${this.config.configDir}`);
19
+ this.log(`Data dir: ${this.config.dataDir}`);
16
20
  }
17
21
  reportActiveProject() {
18
22
  const env = configManager.getCurrentEnv();
@@ -0,0 +1,6 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class TelemetryDisable extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ run(): Promise<void>;
6
+ }
@@ -0,0 +1,14 @@
1
+ import { Command } from '@oclif/core';
2
+ import { configManager } from '../../config/project-config.manager.js';
3
+ export default class TelemetryDisable extends Command {
4
+ static description = 'Disable error reporting to Sentry';
5
+ static examples = ['$ lps telemetry disable'];
6
+ async run() {
7
+ if (configManager.isTelemetryDisabled()) {
8
+ this.log('Error reporting is already disabled.');
9
+ return;
10
+ }
11
+ configManager.setTelemetryDisabled(true);
12
+ this.log('Error reporting disabled.');
13
+ }
14
+ }
@@ -0,0 +1,6 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class TelemetryEnable extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ run(): Promise<void>;
6
+ }
@@ -0,0 +1,14 @@
1
+ import { Command } from '@oclif/core';
2
+ import { configManager } from '../../config/project-config.manager.js';
3
+ export default class TelemetryEnable extends Command {
4
+ static description = 'Enable error reporting to Sentry';
5
+ static examples = ['$ lps telemetry enable'];
6
+ async run() {
7
+ if (!configManager.isTelemetryDisabled()) {
8
+ this.log('Error reporting is already enabled.');
9
+ return;
10
+ }
11
+ configManager.setTelemetryDisabled(false);
12
+ this.log('Error reporting enabled.');
13
+ }
14
+ }
@@ -4,11 +4,13 @@ export interface ConsoleAuth {
4
4
  token: string;
5
5
  }
6
6
  export declare class AuthManager {
7
- private readonly homeDir;
8
- constructor(homeDir?: string);
7
+ private dataDir?;
8
+ constructor(dataDir?: string | undefined);
9
9
  clearAuth(): void;
10
10
  getAuth(): ConsoleAuth | null;
11
11
  getAuthFilePath(): string;
12
12
  setAuth(auth: ConsoleAuth): void;
13
+ setDataDir(dataDir: string): void;
14
+ private requireDataDir;
13
15
  }
14
16
  export declare const authManager: AuthManager;
@@ -1,11 +1,10 @@
1
1
  import { existsSync, unlinkSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
2
  import { join } from 'node:path';
4
3
  import { readJsonFile, writeJsonFileAtomic } from './json-file.js';
5
4
  export class AuthManager {
6
- homeDir;
7
- constructor(homeDir = homedir()) {
8
- this.homeDir = homeDir;
5
+ dataDir;
6
+ constructor(dataDir) {
7
+ this.dataDir = dataDir;
9
8
  }
10
9
  clearAuth() {
11
10
  const filePath = this.getAuthFilePath();
@@ -16,10 +15,21 @@ export class AuthManager {
16
15
  return readJsonFile(this.getAuthFilePath());
17
16
  }
18
17
  getAuthFilePath() {
19
- return join(this.homeDir, '.loopress', 'auth.json');
18
+ return join(this.requireDataDir(), 'auth.json');
20
19
  }
21
20
  setAuth(auth) {
22
21
  writeJsonFileAtomic(this.getAuthFilePath(), auth);
23
22
  }
23
+ // Real CLI runs get this from the `init` hook (src/hooks/init.ts) before any command runs.
24
+ // Throwing when it's unset (rather than falling back to a hardcoded path) surfaces tests that
25
+ // forgot to configure the manager instead of silently touching some default location.
26
+ setDataDir(dataDir) {
27
+ this.dataDir = dataDir;
28
+ }
29
+ requireDataDir() {
30
+ if (!this.dataDir)
31
+ throw new Error('AuthManager used before setDataDir() was called');
32
+ return this.dataDir;
33
+ }
24
34
  }
25
35
  export const authManager = new AuthManager();
@@ -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,15 +19,15 @@ 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
  }
27
26
  }
28
- // Looks up a local project already linked to a given API project, regardless of whether
29
- // it was "claimed" in the current sync run. Used by `project sync` to avoid minting a new
30
- // local project every time it pulls an API project whose local link was lost (e.g. after
31
- // 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.
32
31
  findProjectByApiId(apiProjectId) {
33
32
  const config = this.readConfig();
34
33
  for (const [id, project] of Object.entries(config.projects)) {
@@ -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,15 +1,29 @@
1
- import * as Sentry from '@sentry/node';
2
- import { isTelemetryDisabled, runtimeContext } from '../lib/sentry.js';
1
+ import { isTelemetryDisabled, redactArgv, 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
+ // 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',
23
+ });
10
24
  Sentry.captureException(options.error, {
11
25
  contexts: { runtime: runtimeContext() },
12
- extra: { argv: options.argv },
26
+ extra: { argv: redactArgv(options.argv) },
13
27
  tags: { command: options.id },
14
28
  });
15
29
  await Sentry.flush(2000);
@@ -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,8 +1,8 @@
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(): {
6
5
  node: string;
7
6
  os: string;
8
7
  };
8
+ export declare function redactArgv(argv: string[]): string[];
@@ -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)
@@ -22,3 +20,14 @@ export function runtimeContext() {
22
20
  os: `${platform()} ${release()}`,
23
21
  };
24
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
+ }