@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,8 +1,7 @@
1
- import { confirm } from '@inquirer/prompts';
2
1
  import { Listr } from 'listr2';
3
2
  import { PushCommand } from '../../lib/push-command.js';
4
3
  import { getComposerManagedSlugs, readComposerJson } from '../../utils/composer.js';
5
- import { diffPlugins } from '../../utils/plugins.js';
4
+ import { diffPlugins, parseInstalledPlugins } from '../../utils/plugins.js';
6
5
  export default class Push extends PushCommand {
7
6
  static description = 'Push plugins to WordPress to match loopress.json';
8
7
  static examples = ['$ lps plugin push', '$ lps plugin push --dry-run'];
@@ -25,45 +24,39 @@ export default class Push extends PushCommand {
25
24
  this.log('Run `lps composer push` to deploy them.');
26
25
  }
27
26
  this.log(`Pushing plugins to ${url}`);
28
- const installed = await this.wp.get('loopress/v1/plugins');
29
- const { drifted, toActivate, toInstall } = diffPlugins(filteredManifest, installed);
30
- if (toInstall.length === 0 && toActivate.length === 0 && drifted.length === 0) {
27
+ const raw = await this.wp.get('wp/v2/plugins');
28
+ const installed = parseInstalledPlugins(raw);
29
+ const { toActivate, toInstall } = diffPlugins(filteredManifest, installed);
30
+ if (toInstall.length === 0 && toActivate.length === 0) {
31
31
  this.log('Everything is already in sync.');
32
32
  return;
33
33
  }
34
34
  if (toInstall.length > 0) {
35
35
  this.log(`\nTo install (${toInstall.length}):`);
36
36
  for (const a of toInstall)
37
- this.log(` + ${a.slug} @ ${a.targetVersion}`);
37
+ this.log(` + ${a.slug}`);
38
38
  }
39
39
  if (toActivate.length > 0) {
40
40
  this.log(`\nTo activate (${toActivate.length}):`);
41
41
  for (const a of toActivate)
42
42
  this.log(` ↑ ${a.slug}`);
43
43
  }
44
- if (drifted.length > 0) {
45
- this.log(`\nVersion mismatch (${drifted.length}):`);
46
- for (const a of drifted) {
47
- this.log(` ~ ${a.slug}: site has ${a.currentVersion}, manifest wants ${a.targetVersion}`);
48
- }
49
- }
50
44
  if (this.dryRun)
51
45
  return;
52
- // Install missing plugins and activate them.
46
+ // Installing with `status: active` activates in the same call, so installs never need a
47
+ // separate activation step the way the old custom endpoint's two-step flow did.
53
48
  if (toInstall.length > 0) {
54
49
  await new Listr(toInstall.map((action) => ({
55
- task: async (_ctx, task) => this.installAndActivate(action.slug, action.targetVersion, task),
56
- title: `Install ${action.slug} @ ${action.targetVersion}`,
50
+ task: async (_ctx, task) => this.installPlugin(action.slug, task),
51
+ title: `Install ${action.slug}`,
57
52
  })), { concurrent: false, exitOnError: false }).run();
58
53
  }
59
- // Activate installed-but-inactive plugins without prompting.
60
54
  if (toActivate.length > 0) {
61
55
  await new Listr(toActivate.map((action) => ({
62
- task: async (_ctx, task) => this.activatePlugin(action.slug, task),
56
+ task: async (_ctx, task) => this.activatePlugin(action.file, action.slug, task),
63
57
  title: `Activate ${action.slug}`,
64
58
  })), { concurrent: false, exitOnError: false }).run();
65
59
  }
66
- await this.syncDrifted(drifted);
67
60
  if (this.failedCount > 0) {
68
61
  this.error(`${this.failedCount} plugin${this.failedCount === 1 ? '' : 's'} failed to install or activate.`);
69
62
  }
@@ -75,18 +68,16 @@ export default class Push extends PushCommand {
75
68
  // it falls back to plain logging. Rethrowing on failure (rather than swallowing) is what lets Listr
76
69
  // mark the task as failed (red cross) instead of completed, even though `exitOnError: false` stops
77
70
  // that failure from aborting sibling tasks in the same list.
78
- async activatePlugin(slug, task) {
79
- await this.performPluginAction('activate', { body: { slug }, endpoint: 'loopress/v1/plugins/activate', slug }, task);
71
+ async activatePlugin(file, slug, task) {
72
+ await this.performPluginAction('activate', slug, () => this.wp.put(`wp/v2/plugins/${file}`, { status: 'active' }), task);
80
73
  }
81
- async installAndActivate(slug, version, task) {
82
- await this.performPluginAction('install', { body: { slug, version }, endpoint: 'loopress/v1/plugins/install', slug }, task);
83
- await this.activatePlugin(slug, task);
74
+ async installPlugin(slug, task) {
75
+ await this.performPluginAction('install', slug, () => this.wp.post('wp/v2/plugins', { slug, status: 'active' }), task);
84
76
  }
85
- async performPluginAction(verb, request, task) {
86
- const { body, endpoint, slug } = request;
77
+ async performPluginAction(verb, slug, request, task) {
87
78
  try {
88
- const result = await this.wp.post(endpoint, body);
89
- const message = `✓ ${result.message}`;
79
+ await request();
80
+ const message = `✓ ${slug} ${verb === 'install' ? 'installed and activated' : 'activated'}`;
90
81
  if (task)
91
82
  task.output = message;
92
83
  else
@@ -102,27 +93,4 @@ export default class Push extends PushCommand {
102
93
  throw error;
103
94
  }
104
95
  }
105
- // Prompt per drifted plugin before syncing. Prompts run sequentially on plain stdout,
106
- // before the Listr renderer takes over the terminal for the confirmed subset.
107
- async syncDrifted(drifted) {
108
- const confirmedDrift = [];
109
- for (const action of drifted) {
110
- const proceed = await confirm({
111
- default: false,
112
- message: `${action.slug} is at ${action.currentVersion} on the site but manifest wants ${action.targetVersion}. Sync to ${action.targetVersion}?`,
113
- });
114
- if (proceed) {
115
- confirmedDrift.push(action);
116
- }
117
- else {
118
- this.log(` Skipped ${action.slug}`);
119
- }
120
- }
121
- if (confirmedDrift.length === 0)
122
- return;
123
- await new Listr(confirmedDrift.map((action) => ({
124
- task: async (_ctx, task) => this.installAndActivate(action.slug, action.targetVersion, task),
125
- title: `Sync ${action.slug} to ${action.targetVersion}`,
126
- })), { concurrent: false, exitOnError: false }).run();
127
- }
128
96
  }
@@ -3,5 +3,7 @@ export default class Config extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  run(): Promise<void>;
6
+ private promptManualCredentials;
7
+ private resolveCredentials;
6
8
  private resolveProject;
7
9
  }
@@ -1,9 +1,13 @@
1
1
  import { confirm, input, password as passwordPrompt, select } from '@inquirer/prompts';
2
2
  import { Command } from '@oclif/core';
3
3
  import { configManager } from '../../config/project-config.manager.js';
4
+ import { authorizeWithBrowser } from '../../lib/wp-authorize-flow.js';
5
+ import { diagnoseWpSite } from '../../lib/wp-site-diagnostic.js';
4
6
  const NEW_PROJECT = '__new__';
7
+ const AUTH_BROWSER = 'browser';
8
+ const AUTH_MANUAL = 'manual';
5
9
  export default class Config extends Command {
6
- static description = 'Add or update a WordPress project environment';
10
+ static description = 'Add or update a WordPress project environment. By default, authorizes via WordPress in your browser; manual username/Application Password entry is available as a fallback.';
7
11
  static examples = ['$ lps project config'];
8
12
  async run() {
9
13
  await this.parse(Config);
@@ -34,7 +38,7 @@ export default class Config extends Command {
34
38
  return;
35
39
  }
36
40
  }
37
- const url = await input({
41
+ const rawUrl = await input({
38
42
  message: 'WordPress URL',
39
43
  validate(value) {
40
44
  try {
@@ -49,15 +53,8 @@ export default class Config extends Command {
49
53
  }
50
54
  },
51
55
  });
52
- const user = await input({
53
- message: 'Username',
54
- validate: (value) => (value.trim().length > 0 ? true : 'Username cannot be empty'),
55
- });
56
- const appPassword = await passwordPrompt({
57
- mask: '*',
58
- message: 'Application password',
59
- validate: (value) => (value.trim().length > 0 ? true : 'Application password cannot be empty'),
60
- });
56
+ const url = rawUrl.replace(/\/+$/, '');
57
+ const { appPassword, user } = await this.resolveCredentials(url);
61
58
  const token = `${user}:${appPassword}`;
62
59
  const env = {
63
60
  addedAt: new Date().toISOString(),
@@ -79,6 +76,43 @@ export default class Config extends Command {
79
76
  this.log(`✓ "${projectName}/${envName}" configured`);
80
77
  this.log('→ Run `lps project switch` to change the active project or environment');
81
78
  }
79
+ async promptManualCredentials() {
80
+ const user = await input({
81
+ message: 'Username',
82
+ validate: (value) => (value.trim().length > 0 ? true : 'Username cannot be empty'),
83
+ });
84
+ const appPassword = await passwordPrompt({
85
+ mask: '*',
86
+ message: 'Application password',
87
+ validate: (value) => (value.trim().length > 0 ? true : 'Application password cannot be empty'),
88
+ });
89
+ return { appPassword, user };
90
+ }
91
+ async resolveCredentials(url) {
92
+ const authMode = await select({
93
+ choices: [
94
+ { name: 'Authorize in my browser (recommended)', value: AUTH_BROWSER },
95
+ { name: 'Enter credentials manually', value: AUTH_MANUAL },
96
+ ],
97
+ message: 'How do you want to authenticate?',
98
+ });
99
+ if (authMode === AUTH_MANUAL) {
100
+ return this.promptManualCredentials();
101
+ }
102
+ const diagnostic = await diagnoseWpSite(url);
103
+ if (!diagnostic.ok) {
104
+ this.warn(`${diagnostic.reason}\nFalling back to manual credential entry.`);
105
+ return this.promptManualCredentials();
106
+ }
107
+ try {
108
+ const { password, userLogin } = await authorizeWithBrowser(url, (message) => this.log(message));
109
+ return { appPassword: password, user: userLogin };
110
+ }
111
+ catch (error) {
112
+ this.warn(`${error.message}\nFalling back to manual credential entry.`);
113
+ return this.promptManualCredentials();
114
+ }
115
+ }
82
116
  async resolveProject() {
83
117
  const projects = configManager.listProjects();
84
118
  if (projects.length > 0) {
@@ -0,0 +1,10 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Publish extends Command {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ run(): Promise<void>;
9
+ private toPayload;
10
+ }
@@ -0,0 +1,74 @@
1
+ import { Args, Command } from '@oclif/core';
2
+ import { join } from 'node:path';
3
+ import slugify from 'slugify';
4
+ import { authManager } from '../../config/auth.manager.js';
5
+ import { configManager } from '../../config/project-config.manager.js';
6
+ import { ApiClient } from '../../lib/api-client.js';
7
+ import { loadSnippets } from '../../lib/load-snippets.js';
8
+ import { readLocalConfig } from '../../utils/loopress-config.js';
9
+ // Publishes to the Loopress api (not a WordPress site), so this does not extend
10
+ // `LoopressCommand`/`PushCommand`: those force an environment to be resolved, but a project
11
+ // publishing its snippets for sharing doesn't need one, a pure "library" project
12
+ // may have zero environments configured at all.
13
+ export default class Publish extends Command {
14
+ static args = {
15
+ path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
16
+ };
17
+ static description = 'Publish snippets to your Loopress account so they can be deployed to other projects. Does not touch any WordPress site.';
18
+ static examples = ['$ lps snippet publish', '$ lps snippet publish --path ./snippets'];
19
+ async run() {
20
+ const { args } = await this.parse(Publish);
21
+ const token = process.env.LOOPRESS_TOKEN ?? authManager.getAuth()?.token;
22
+ if (!token) {
23
+ this.error('Not logged in. Run `lps login` first.');
24
+ }
25
+ const localConfig = await readLocalConfig();
26
+ const projectId = localConfig.projectId ?? configManager.getCurrentProject()?.id;
27
+ if (!projectId) {
28
+ this.error('No project configured. Run `lps project config` first.');
29
+ }
30
+ const project = configManager.getProject(projectId);
31
+ if (!project) {
32
+ this.error(`Project "${projectId}" (from loopress.json) not found. Run \`lps project config\` to configure it.`);
33
+ }
34
+ if (!project.apiProjectId) {
35
+ this.error(`Project "${project.name}" is not linked to your Loopress account yet. Run \`lps project sync\` first.`);
36
+ }
37
+ const path = args.path ?? join(localConfig.rootDir ?? '.', localConfig.snippetsDir ?? 'snippets');
38
+ this.log(`Publishing snippets from ${path}`);
39
+ let snippets;
40
+ try {
41
+ snippets = await loadSnippets(path);
42
+ }
43
+ catch (error) {
44
+ this.error(error.message);
45
+ }
46
+ const api = new ApiClient(token);
47
+ try {
48
+ await api.post(`projects/${project.apiProjectId}/snippets/publish`, {
49
+ snippets: snippets.map((snippet) => this.toPayload(snippet)),
50
+ });
51
+ }
52
+ catch (error) {
53
+ this.error(error.message);
54
+ }
55
+ this.log(`Published ${snippets.length} snippet${snippets.length === 1 ? '' : 's'} to your Loopress account.`);
56
+ }
57
+ toPayload(snippet) {
58
+ return {
59
+ active: snippet.active,
60
+ code: snippet.code,
61
+ insertMethod: snippet.insertMethod,
62
+ location: snippet.location,
63
+ name: snippet.name,
64
+ priority: snippet.priority,
65
+ shortcodeAttributes: snippet.shortcodeAttributes,
66
+ // Derived from the snippet's name rather than its on-disk filename: the same
67
+ // slugification `push`/`pull` already use for the canonical `<id>-<slug>` filename
68
+ // convention, so it stays stable even for a not-yet-pushed file with an arbitrary name.
69
+ slug: slugify(snippet.name, { lower: true, strict: true }),
70
+ tags: snippet.tags,
71
+ type: snippet.type,
72
+ };
73
+ }
74
+ }
@@ -1,18 +1,12 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { Listr } from 'listr2';
3
- import { readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { basename, dirname, extname, join } from 'node:path';
5
5
  import slugify from 'slugify';
6
+ import { loadSnippets as loadSnippetsFromDisk } from '../../lib/load-snippets.js';
6
7
  import { PushCommand } from '../../lib/push-command.js';
7
8
  import { isNotFoundError } from '../../lib/wp-client.js';
8
- import { defaultLocationForType, normalizeSnippet, parseInsertMethod, parseLocation, parseType, SNIPPETS_ENDPOINT, stripPhpOpeningTag, } from '../../utils/snippet-format.js';
9
- const TYPE_BY_EXTENSION = {
10
- '.css': 'css',
11
- '.html': 'html',
12
- '.js': 'js',
13
- '.php': 'php',
14
- '.txt': 'text',
15
- };
9
+ import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../../utils/snippet-format.js';
16
10
  export default class Push extends PushCommand {
17
11
  static args = {
18
12
  path: Args.string({ description: 'Path to snippets directory (overrides project config)' }),
@@ -78,71 +72,12 @@ export default class Push extends PushCommand {
78
72
  await rm(oldMetaPath, { force: true });
79
73
  }
80
74
  async loadSnippets(path) {
81
- const snippets = [];
82
- let files;
83
75
  try {
84
- files = await readdir(path);
76
+ return await loadSnippetsFromDisk(path, (message) => this.warn(message));
85
77
  }
86
78
  catch (error) {
87
- this.error(`Error loading snippets: ${error.message}`);
79
+ this.error(error.message);
88
80
  }
89
- for (const file of files) {
90
- const ext = extname(file);
91
- if (!(ext in TYPE_BY_EXTENSION))
92
- continue;
93
- const filePath = join(path, file);
94
- const metaPath = join(path, `${basename(file, ext)}.json`);
95
- // One snippet's files are read in isolation: a corrupted or hand-broken sidecar
96
- // (bad JSON, unreadable file, ...) must only skip that snippet, not abort the entire
97
- // push for every other snippet in the directory.
98
- try {
99
- const content = await readFile(filePath, 'utf8');
100
- let id;
101
- let name;
102
- let type;
103
- let active = false;
104
- let tags = [];
105
- let location = null;
106
- let insertMethod = null;
107
- let priority = 10;
108
- let shortcodeAttributes = [];
109
- try {
110
- const metaContent = await readFile(metaPath, 'utf8');
111
- const meta = JSON.parse(metaContent);
112
- id = meta.id ? Number(meta.id) : undefined;
113
- name = meta.name ? String(meta.name) : undefined;
114
- type = parseType(meta.type) ?? undefined;
115
- active = Boolean(meta.active);
116
- tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
117
- location = parseLocation(meta.location);
118
- insertMethod = parseInsertMethod(meta.insertMethod);
119
- priority = meta.priority === undefined ? 10 : Number(meta.priority);
120
- shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
121
- }
122
- catch (error) {
123
- if (error.code !== 'ENOENT')
124
- throw error;
125
- }
126
- const resolvedType = type ?? (ext in TYPE_BY_EXTENSION ? TYPE_BY_EXTENSION[ext] : 'php');
127
- snippets.push({
128
- active,
129
- code: content,
130
- id,
131
- insertMethod: insertMethod ?? 'auto',
132
- location: location ?? defaultLocationForType(resolvedType),
133
- name: name ?? basename(file, ext),
134
- path: filePath,
135
- priority,
136
- shortcodeAttributes,
137
- tags,
138
- type: resolvedType,
139
- });
140
- }
141
- catch (error) {
142
- this.warn(`Skipping "${metaPath}": ${error.message}`);
143
- }
144
- }
145
- return snippets;
146
81
  }
147
82
  // Throwing on failure (rather than returning a boolean) is what lets Listr mark the task as
148
83
  // failed (red cross) instead of completed; `exitOnError: false` on the task list still lets
@@ -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,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();