@get-dx/cli 0.7.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.
@@ -1,15 +1,82 @@
1
+ import fs from "fs";
1
2
  import { Command } from "commander";
3
+ import { parse as parseYaml } from "yaml";
2
4
  import { createExampleText, getContext, wrapAction, } from "../commandHelpers.js";
3
5
  import { CliError, EXIT_CODES } from "../errors.js";
4
6
  import { request } from "../http.js";
5
- import { renderJson } from "../renderers.js";
7
+ import { renderJson, renderRichText } from "../renderers.js";
6
8
  import { buildRuntime } from "../runtime.js";
7
- import { renderWorkflowList } from "./workflowsRendering.js";
9
+ import * as ui from "../ui.js";
10
+ import { renderWorkflowCreated, renderWorkflowList, } from "./workflowsRendering.js";
11
+ const WORKFLOW_BLANK_TEMPLATE_YAML = fs.readFileSync(new URL("./workflow-blank-template.yaml", import.meta.url), "utf8");
8
12
  const WORKFLOW_SCOPES = ["GLOBAL", "ENTITY"];
9
13
  export function workflowsCommand() {
10
14
  const workflows = new Command()
11
15
  .name("workflows")
12
16
  .description("Manage Self-service workflow definitions");
17
+ workflows
18
+ .command("create")
19
+ .description("Create a Self-service workflow from a YAML or JSON file or stdin. The `init` command can be used to generate a starting template.")
20
+ .option("--from-file <path>", "Read a YAML or JSON file and create a workflow from its contents")
21
+ .option("--from-stdin", "Read YAML or JSON from stdin and create a workflow from its contents")
22
+ .addHelpText("afterAll", createExampleText([
23
+ {
24
+ label: "Create a workflow from a file",
25
+ command: "dx workflows create --from-file workflow.yaml",
26
+ },
27
+ {
28
+ label: "Create a workflow from stdin",
29
+ command: "cat workflow.json | dx workflows create --from-stdin",
30
+ },
31
+ {
32
+ label: "Same request with JSON output",
33
+ command: "dx --json workflows create --from-file workflow.yaml",
34
+ },
35
+ ]))
36
+ .action(wrapAction(async (options, command) => {
37
+ const modeCount = [options.fromFile, options.fromStdin].filter(Boolean).length;
38
+ if (modeCount === 0) {
39
+ throw new CliError("One of --from-file or --from-stdin is required", EXIT_CODES.ARGUMENT_ERROR);
40
+ }
41
+ if (modeCount > 1) {
42
+ throw new CliError("--from-file and --from-stdin are mutually exclusive", EXIT_CODES.ARGUMENT_ERROR);
43
+ }
44
+ const runtime = await buildRuntime(getContext(command));
45
+ const raw = options.fromFile
46
+ ? readYamlFile(options.fromFile)
47
+ : await readYamlStdin();
48
+ const payload = parsePayloadObject(raw);
49
+ const response = await createWorkflow(runtime, payload);
50
+ if (runtime.context.json) {
51
+ renderJson(response);
52
+ }
53
+ else {
54
+ renderWorkflowCreated(response.workflow);
55
+ }
56
+ }));
57
+ workflows
58
+ .command("init")
59
+ .description("Write a blank Self-service workflow YAML template to a file")
60
+ .argument("<path>", "File path to write the YAML to")
61
+ .addHelpText("afterAll", createExampleText([
62
+ {
63
+ label: "Write a blank workflow template",
64
+ command: "dx workflows init ./my-workflow.yaml",
65
+ },
66
+ ]))
67
+ .action(wrapAction(async (path, _options, command) => {
68
+ const runtime = await buildRuntime(getContext(command));
69
+ fs.writeFileSync(path, WORKFLOW_BLANK_TEMPLATE_YAML, "utf8");
70
+ if (runtime.context.json) {
71
+ renderJson({ ok: true, path });
72
+ }
73
+ else {
74
+ renderRichText([
75
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Blank template written to ${ui.code(path)}.`),
76
+ ui.p(`Edit the file, then run: ${ui.code(`dx workflows create --from-file ${path}`)}`),
77
+ ]);
78
+ }
79
+ }));
13
80
  workflows
14
81
  .command("list")
15
82
  .description("List Self-service workflows for the account")
@@ -49,6 +116,45 @@ export function workflowsCommand() {
49
116
  }));
50
117
  return workflows;
51
118
  }
119
+ function readYamlFile(filePath) {
120
+ let content;
121
+ try {
122
+ content = fs.readFileSync(filePath, "utf8");
123
+ }
124
+ catch (err) {
125
+ throw new CliError(`Could not read file "${filePath}": ${err.message}`, EXIT_CODES.ARGUMENT_ERROR);
126
+ }
127
+ return parseYamlContent(content, `file "${filePath}"`);
128
+ }
129
+ async function readYamlStdin() {
130
+ return new Promise((resolve, reject) => {
131
+ const chunks = [];
132
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
133
+ process.stdin.on("end", () => {
134
+ try {
135
+ resolve(parseYamlContent(Buffer.concat(chunks).toString("utf8"), "stdin"));
136
+ }
137
+ catch (err) {
138
+ reject(err);
139
+ }
140
+ });
141
+ process.stdin.on("error", (err) => reject(new CliError(`Failed to read from stdin: ${err.message}`, EXIT_CODES.ARGUMENT_ERROR)));
142
+ });
143
+ }
144
+ function parseYamlContent(content, source) {
145
+ try {
146
+ return parseYaml(content);
147
+ }
148
+ catch (err) {
149
+ throw new CliError(`Failed to parse YAML or JSON from ${source}: ${err.message}`, EXIT_CODES.ARGUMENT_ERROR);
150
+ }
151
+ }
152
+ function parsePayloadObject(raw) {
153
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
154
+ throw new CliError("Content must be a YAML or JSON object", EXIT_CODES.ARGUMENT_ERROR);
155
+ }
156
+ return raw;
157
+ }
52
158
  function parseOptionalTrimmed(value) {
53
159
  if (typeof value !== "string") {
54
160
  return undefined;
@@ -67,6 +173,10 @@ function parseOptionalScope(value) {
67
173
  return raw;
68
174
  }
69
175
  // --- API ---
176
+ export async function createWorkflow(runtime, payload) {
177
+ const response = await request(runtime, "/workflows.create", { method: "POST", body: payload });
178
+ return response.body;
179
+ }
70
180
  export async function listWorkflows(runtime, params) {
71
181
  const query = {};
72
182
  if (params.scope !== undefined)
@@ -38,18 +38,30 @@ export function renderWorkflowList(workflows) {
38
38
  ui.p(`Displaying ${ui.bold(workflows.length.toString())} workflows.`),
39
39
  ];
40
40
  for (const workflow of workflows) {
41
- blocks.push(ui.h2(`${workflow.name} (${ui.code(workflow.identifier)})`));
42
- blocks.push(ui.h3("Core attributes"));
43
- blocks.push(...coreContent(workflow));
44
- if (workflow.scope === "ENTITY") {
45
- blocks.push(ui.h3("Entity filter"));
46
- blocks.push(...entityFilterContent(workflow));
47
- }
48
- blocks.push(ui.h3("Parameters"));
49
- blocks.push(...parametersContent(workflow));
41
+ blocks.push(...workflowContent(workflow));
50
42
  }
51
43
  renderRichText(blocks);
52
44
  }
45
+ export function renderWorkflowCreated(workflow) {
46
+ renderRichText([
47
+ ui.p(`${ui.success(ui.GLYPHS.CHECK)} Workflow created: ${ui.code(workflow.identifier)}`),
48
+ ...workflowContent(workflow),
49
+ ]);
50
+ }
51
+ function workflowContent(workflow) {
52
+ const blocks = [
53
+ ui.h2(`${workflow.name} (${ui.code(workflow.identifier)})`),
54
+ ui.h3("Core attributes"),
55
+ ...coreContent(workflow),
56
+ ];
57
+ if (workflow.scope === "ENTITY") {
58
+ blocks.push(ui.h3("Entity filter"));
59
+ blocks.push(...entityFilterContent(workflow));
60
+ }
61
+ blocks.push(ui.h3("Parameters"));
62
+ blocks.push(...parametersContent(workflow));
63
+ return blocks;
64
+ }
53
65
  function coreContent(workflow) {
54
66
  const blocks = [];
55
67
  const description = workflow.description?.trim()
package/dist/config.js CHANGED
@@ -41,6 +41,53 @@ export function readConfig() {
41
41
  }
42
42
  return stored;
43
43
  }
44
+ /**
45
+ * Report on the state of the config file without hiding problems, unlike
46
+ * `readConfig`, which treats an unparseable file as empty.
47
+ */
48
+ export function inspectConfigFile() {
49
+ const configPath = getConfigPath();
50
+ let stats;
51
+ let content;
52
+ try {
53
+ stats = fs.statSync(configPath);
54
+ content = fs.readFileSync(configPath, "utf8");
55
+ }
56
+ catch (error) {
57
+ if (error.code === "ENOENT") {
58
+ return { status: "missing", path: configPath };
59
+ }
60
+ return {
61
+ status: "invalid",
62
+ path: configPath,
63
+ error: error instanceof Error ? error.message : String(error),
64
+ };
65
+ }
66
+ try {
67
+ const parsed = JSON.parse(content);
68
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
69
+ return {
70
+ status: "invalid",
71
+ path: configPath,
72
+ error: "expected a JSON object",
73
+ };
74
+ }
75
+ }
76
+ catch (error) {
77
+ return {
78
+ status: "invalid",
79
+ path: configPath,
80
+ error: error instanceof Error ? error.message : String(error),
81
+ };
82
+ }
83
+ return {
84
+ status: "ok",
85
+ path: configPath,
86
+ config: readConfig(),
87
+ sizeBytes: stats.size,
88
+ modifiedAt: stats.mtime,
89
+ };
90
+ }
44
91
  export function persistCurrentVersionCache(cache) {
45
92
  writeConfig({ ...readConfig(), currentVersionCache: cache });
46
93
  }
@@ -89,20 +136,31 @@ function getConfigDir() {
89
136
  * Precedence: `DX_API_BASE_URL`, then persisted `apiBaseUrl`, then DX Cloud default.
90
137
  */
91
138
  export function resolveApiBaseUrl() {
92
- if (process.env.DX_API_BASE_URL) {
93
- return normalizeUrl(process.env.DX_API_BASE_URL);
94
- }
95
- return normalizeUrl(readConfig().apiBaseUrl || DEFAULT_API_BASE_URL);
139
+ return resolveApiBaseUrlWithSource().url;
140
+ }
141
+ /** Like `resolveApiBaseUrl`, but also reports where the value came from. */
142
+ export function resolveApiBaseUrlWithSource() {
143
+ return resolveBaseUrl(process.env.DX_API_BASE_URL, readConfig().apiBaseUrl, DEFAULT_API_BASE_URL);
96
144
  }
97
145
  /**
98
146
  * Resolved web-app base URL for CLI deep links and browser OAuth.
99
147
  * Precedence: `DX_WEB_BASE_URL`, persisted `webBaseUrl`, then DX Cloud default.
100
148
  */
101
149
  export function resolveWebBaseUrl() {
102
- if (process.env.DX_WEB_BASE_URL) {
103
- return normalizeUrl(process.env.DX_WEB_BASE_URL);
150
+ return resolveWebBaseUrlWithSource().url;
151
+ }
152
+ /** Like `resolveWebBaseUrl`, but also reports where the value came from. */
153
+ export function resolveWebBaseUrlWithSource() {
154
+ return resolveBaseUrl(process.env.DX_WEB_BASE_URL, readConfig().webBaseUrl, DEFAULT_WEB_BASE_URL);
155
+ }
156
+ function resolveBaseUrl(fromEnv, fromConfig, fallback) {
157
+ if (fromEnv) {
158
+ return { url: normalizeUrl(fromEnv), source: "env" };
104
159
  }
105
- return normalizeUrl(readConfig().webBaseUrl || DEFAULT_WEB_BASE_URL);
160
+ if (fromConfig) {
161
+ return { url: normalizeUrl(fromConfig), source: "config" };
162
+ }
163
+ return { url: normalizeUrl(fallback), source: "default" };
106
164
  }
107
165
  // --- Deriving base URLs at login time -------------------------------------
108
166
  /**
@@ -151,14 +209,17 @@ export function deriveBaseUrlsFromEnv() {
151
209
  throw new CliError(`Could not recognize web base URL "${parsed.input}". Expected https://app.getdx.com, https://<account>.getdx.io, or a custom domain.`);
152
210
  }
153
211
  }
154
- function parseWebBaseUrl(raw) {
212
+ export function parseWebBaseUrl(raw) {
155
213
  const normalized = raw.trim().replace(/\/$/, "");
156
- if (!normalized || normalized === "app.getdx.com") {
214
+ if (!normalized) {
157
215
  return { type: "cloud" };
158
216
  }
159
217
  try {
160
218
  const url = new URL(normalized.startsWith("http") ? normalized : `https://${normalized}`);
161
219
  const host = url.hostname;
220
+ if (host === "app.getdx.com") {
221
+ return { type: "cloud" };
222
+ }
162
223
  const dedicatedMatch = host.match(/^(.+)\.getdx\.io$/);
163
224
  if (dedicatedMatch) {
164
225
  return { type: "dedicated", accountName: dedicatedMatch[1] };
@@ -0,0 +1,133 @@
1
+ import dayjs from "dayjs";
2
+ import { getAuthInfo } from "../commands/auth.js";
3
+ import { HttpError } from "../errors.js";
4
+ import { describeError, timed } from "./format.js";
5
+ import { CHECK_TIMEOUT_MS, DOCTOR_HEADER } from "./probes.js";
6
+ import { check } from "./types.js";
7
+ const EXPIRY_WARNING_DAYS = 7;
8
+ const SECTION = { id: "auth", title: "Auth" };
9
+ export async function checkAuth(options) {
10
+ const { runtime, apiReachable, now } = options;
11
+ if (!runtime) {
12
+ return skipped("no token is configured");
13
+ }
14
+ if (!apiReachable) {
15
+ return skipped("the API is unreachable");
16
+ }
17
+ const host = new URL(runtime.apiBaseUrl).host;
18
+ let info;
19
+ let ms;
20
+ try {
21
+ ({ value: info, ms } = await timed(() => getAuthInfo(runtime, {
22
+ headers: DOCTOR_HEADER,
23
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
24
+ })));
25
+ }
26
+ catch (error) {
27
+ return { ...SECTION, checks: [tokenErrorCheck(error, host)] };
28
+ }
29
+ const { auth, account } = info;
30
+ const checks = [
31
+ check("token_valid", "Token valid", "ok", `${tokenTypeName(auth.token_type)} "${auth.token_name}" (responded in ${ms}ms)`, {
32
+ data: {
33
+ token_type: auth.token_type,
34
+ token_name: auth.token_name,
35
+ scopes: auth.scopes,
36
+ created_at: auth.created_at,
37
+ ms,
38
+ },
39
+ }),
40
+ check("account", "Account", "ok", account.name, {
41
+ data: { name: account.name },
42
+ }),
43
+ ];
44
+ if (auth.token_type === "personal_access_token") {
45
+ checks.push(expiryCheck(auth.expires_at, now));
46
+ }
47
+ return { ...SECTION, checks };
48
+ }
49
+ function skipped(reason) {
50
+ return {
51
+ ...SECTION,
52
+ checks: [
53
+ check("token_valid", "Token valid", "skip", `skipped because ${reason}`),
54
+ ],
55
+ };
56
+ }
57
+ function tokenErrorCheck(error, host) {
58
+ if (error instanceof HttpError && error.status === 401) {
59
+ return check("token_valid", "Token valid", "fail", `token rejected by ${host} (HTTP 401: ${error.message})`, {
60
+ hint: "The token may be revoked, expired, or issued by a different DX instance. Run `dx auth login` to store a new one.",
61
+ data: { status: 401 },
62
+ });
63
+ }
64
+ if (error instanceof HttpError && error.status !== undefined) {
65
+ const { message, hint } = describeHttpFailure(error.status, error.message, host);
66
+ return check("token_valid", "Token valid", "fail", message, {
67
+ hint,
68
+ data: { status: error.status },
69
+ });
70
+ }
71
+ return check("token_valid", "Token valid", "fail", describeError(error, "auth.info request"), {
72
+ hint: `The API was reachable a moment ago, but this request to ${host} failed. Try again.`,
73
+ data: { status: null },
74
+ });
75
+ }
76
+ function describeHttpFailure(status, errorMessage, host) {
77
+ if (status === 403) {
78
+ return {
79
+ message: `access denied by ${host} (HTTP 403: ${errorMessage})`,
80
+ hint: "The token was recognized but this request was refused. An IP allowlist, proxy, or firewall may be blocking it. Check with your DX admin or network team.",
81
+ };
82
+ }
83
+ if (status === 429) {
84
+ return {
85
+ message: `rate limited by ${host} (HTTP 429)`,
86
+ hint: "Too many requests. Wait a minute and run `dx doctor` again.",
87
+ };
88
+ }
89
+ if (status >= 500) {
90
+ return {
91
+ message: `HTTP ${status}: ${errorMessage}`,
92
+ hint: "The DX API is having problems. Try again in a few minutes, and contact DX support if this persists.",
93
+ };
94
+ }
95
+ return {
96
+ message: `HTTP ${status}: ${errorMessage}`,
97
+ hint: "The DX API could not validate the token. Try again, and contact DX support if this persists.",
98
+ };
99
+ }
100
+ function expiryCheck(expiresAt, now) {
101
+ if (!expiresAt) {
102
+ return check("expires", "Expires", "ok", "never", {
103
+ data: { expires_at: null },
104
+ });
105
+ }
106
+ const expires = dayjs(expiresAt);
107
+ const days = expires.diff(dayjs(now), "day");
108
+ const date = expires.format("YYYY-MM-DD");
109
+ const data = { expires_at: expiresAt, days_remaining: days };
110
+ if (expires.isBefore(now)) {
111
+ return check("expires", "Expires", "fail", `expired on ${date}`, {
112
+ hint: "Create a new personal access token and run `dx auth login`.",
113
+ data,
114
+ });
115
+ }
116
+ if (days < EXPIRY_WARNING_DAYS) {
117
+ return check("expires", "Expires", "warn", `${date} (${days}d)`, {
118
+ hint: "This token expires soon. Create a new one and run `dx auth login`.",
119
+ data,
120
+ });
121
+ }
122
+ return check("expires", "Expires", "ok", `${date} (${days}d)`, { data });
123
+ }
124
+ function tokenTypeName(tokenType) {
125
+ switch (tokenType) {
126
+ case "account_web_api_token":
127
+ return "organization token";
128
+ case "personal_access_token":
129
+ return "personal access token";
130
+ default:
131
+ return String(tokenType);
132
+ }
133
+ }
@@ -0,0 +1,154 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import dayjs from "dayjs";
4
+ import { compareVersions, fetchLatestVersion, fetchLatestVersionFromNpm, } from "../versionCheck.js";
5
+ import { describeError, tildify } from "./format.js";
6
+ import { CHECK_TIMEOUT_MS, DOCTOR_HEADER } from "./probes.js";
7
+ import { check } from "./types.js";
8
+ import cliPackage from "../../package.json" with { type: "json" };
9
+ export const defaultLatestVersionLookup = {
10
+ fromApi: (runtime) => fetchLatestVersion(runtime, {
11
+ headers: DOCTOR_HEADER,
12
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
13
+ }),
14
+ fromNpm: () => fetchLatestVersionFromNpm({
15
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
16
+ }),
17
+ };
18
+ export async function checkCli(options) {
19
+ const { runtime, apiReachable, install, latestVersion, now } = options;
20
+ const checks = [
21
+ await versionCheck(apiReachable ? runtime : null, install.method, latestVersion),
22
+ nodeCheck(),
23
+ check("platform", "Platform", "ok", `${process.platform}/${process.arch}`, {
24
+ data: { platform: process.platform, arch: process.arch },
25
+ }),
26
+ check("install", "Install", "ok", `${installMethodName(install.method)} (${tildify(install.path)})`, { data: { method: install.method, path: tildify(install.path) } }),
27
+ check("time", "Time", "ok", dayjs(now).format(), {
28
+ data: { time: dayjs(now).format() },
29
+ }),
30
+ ];
31
+ return { id: "cli", title: "CLI", checks };
32
+ }
33
+ async function versionCheck(runtime, method, lookup) {
34
+ const current = cliPackage.version;
35
+ const failures = [];
36
+ let latest = null;
37
+ let latestSource = null;
38
+ if (runtime) {
39
+ try {
40
+ latest = await lookup.fromApi(runtime);
41
+ latestSource = "api";
42
+ }
43
+ catch (error) {
44
+ failures.push(`DX API: ${describeError(error, "request")}`);
45
+ }
46
+ }
47
+ else {
48
+ failures.push("DX API: skipped (no token or API unreachable)");
49
+ }
50
+ if (!latest) {
51
+ try {
52
+ latest = await lookup.fromNpm();
53
+ latestSource = "npm";
54
+ }
55
+ catch (error) {
56
+ failures.push(`npm: ${describeError(error, "request")}`);
57
+ }
58
+ }
59
+ if (!latest) {
60
+ return check("version", "Version", "fail", `${current} (could not determine the latest version)`, {
61
+ hint: `Tried the DX API and the npm registry:\n${failures.join("\n")}`,
62
+ data: { current, latest: null, latest_source: null },
63
+ });
64
+ }
65
+ const data = { current, latest, latest_source: latestSource };
66
+ const comparison = compareVersions(current, latest);
67
+ if (comparison < 0) {
68
+ return check("version", "Version", "warn", `${current} (latest is ${latest})`, {
69
+ hint: `Upgrade with: ${upgradeCommand(method)}`,
70
+ data,
71
+ });
72
+ }
73
+ const message = comparison === 0
74
+ ? `${current} (up to date)`
75
+ : `${current} (newer than the latest release, ${latest})`;
76
+ return check("version", "Version", "ok", message, { data });
77
+ }
78
+ function nodeCheck() {
79
+ const version = process.version;
80
+ const supportedMajors = [
81
+ ...cliPackage.engines.node.matchAll(/\^(\d+)\./g),
82
+ ].map((match) => Number(match[1]));
83
+ const major = Number(process.versions.node.split(".")[0]);
84
+ const data = { version, supported: cliPackage.engines.node };
85
+ if (supportedMajors.length > 0 && !supportedMajors.includes(major)) {
86
+ return check("node", "Node.js", "warn", `${version} (unsupported)`, {
87
+ hint: `dx-cli supports Node.js ${cliPackage.engines.node}. Other versions may not work correctly.`,
88
+ data,
89
+ });
90
+ }
91
+ return check("node", "Node.js", "ok", version, { data });
92
+ }
93
+ export function upgradeCommand(method) {
94
+ switch (method) {
95
+ case "npx":
96
+ return "npx @get-dx/cli@latest";
97
+ case "dev":
98
+ return "pull the latest changes and run `make`";
99
+ case "pnpm":
100
+ return "pnpm add -g @get-dx/cli@latest";
101
+ case "npm":
102
+ case "unknown":
103
+ return "npm install -g @get-dx/cli@latest";
104
+ }
105
+ }
106
+ function installMethodName(method) {
107
+ switch (method) {
108
+ case "npx":
109
+ return "npx";
110
+ case "dev":
111
+ return "pnpm link (dev)";
112
+ case "pnpm":
113
+ return "pnpm global";
114
+ case "npm":
115
+ return "npm global";
116
+ case "unknown":
117
+ return "unknown";
118
+ }
119
+ }
120
+ /**
121
+ * Work out how the CLI was installed from where its entry script lives.
122
+ */
123
+ export function detectInstall(entryScript = process.argv[1], execPath = process.execPath) {
124
+ let resolved = entryScript;
125
+ try {
126
+ resolved = fs.realpathSync(entryScript);
127
+ }
128
+ catch {
129
+ // Fall back to the unresolved path.
130
+ }
131
+ // `dist/index.js` lives one level below the package root.
132
+ const packageRoot = path.dirname(path.dirname(resolved));
133
+ const normalized = resolved.split(path.sep).join("/");
134
+ if (normalized.includes("/_npx/")) {
135
+ return { method: "npx", path: packageRoot };
136
+ }
137
+ if (!normalized.includes("/node_modules/")) {
138
+ if (fs.existsSync(path.join(packageRoot, ".git"))) {
139
+ return { method: "dev", path: packageRoot };
140
+ }
141
+ return { method: "unknown", path: resolved };
142
+ }
143
+ if (normalized.includes("/pnpm/global/") || normalized.includes("/.pnpm/")) {
144
+ return { method: "pnpm", path: packageRoot };
145
+ }
146
+ const npmGlobalRoot = process.platform === "win32"
147
+ ? path.join(path.dirname(execPath), "node_modules")
148
+ : path.join(path.dirname(path.dirname(execPath)), "lib", "node_modules");
149
+ if (resolved.startsWith(npmGlobalRoot + path.sep) ||
150
+ normalized.includes("/lib/node_modules/@get-dx/cli/")) {
151
+ return { method: "npm", path: packageRoot };
152
+ }
153
+ return { method: "unknown", path: packageRoot };
154
+ }