@zeroheight/adoption-cli 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -14,7 +14,7 @@ In the repository in which you wish to analyze the component usage, run the foll
14
14
  zh-adoption analyze
15
15
  ```
16
16
 
17
- To send adoption data to your zeroheight account you will need to authenticate. This can be done as part of the `analyze` flow or separetly by running:
17
+ To send adoption data to your zeroheight account you will need to authenticate. This can be done as part of the `analyze` flow or separately by running:
18
18
 
19
19
  ```
20
20
  zh-adoption auth
@@ -27,6 +27,8 @@ export function analyze(ast) {
27
27
  JSXElement(node, state, _nodePath) {
28
28
  const el = node.openingElement;
29
29
  const elName = el.name.name;
30
+ if (!elName)
31
+ return;
30
32
  // Ignore html tags e.g. div, h1, span
31
33
  if (elName[0] === elName[0]?.toLocaleLowerCase())
32
34
  return;
@@ -69,6 +71,10 @@ export function analyze(ast) {
69
71
  TSAsExpression() { },
70
72
  TSDeclareFunction() { },
71
73
  TSTypeAliasDeclaration() { },
74
+ TSNonNullExpression() { },
75
+ TSEnumDeclaration() { },
76
+ TSParameterProperty() { },
77
+ TSImportEqualsDeclaration() { },
72
78
  };
73
79
  try {
74
80
  walk.ancestor(ast, visitors, base, visitorState);
@@ -5,6 +5,7 @@ interface AnalyzeOptions {
5
5
  extensions: string;
6
6
  ignore: string;
7
7
  dryRun: boolean;
8
+ repoName?: string;
8
9
  }
9
10
  export type RawUsageMap = Map<string, RawUsage[]>;
10
11
  export declare function analyzeAction(options: AnalyzeOptions, renderOptions?: RenderOptions): Promise<void>;
@@ -4,7 +4,7 @@ import { Command, Option } from "commander";
4
4
  import Analyze from "../components/analyze/analyze.js";
5
5
  import { analyzeFiles } from "./analyze.utils.js";
6
6
  export async function analyzeAction(options, renderOptions) {
7
- render(React.createElement(Analyze, { onAnalyzeFiles: () => analyzeFiles(options.extensions, options.ignore), dryRun: options.dryRun }), renderOptions);
7
+ render(React.createElement(Analyze, { onAnalyzeFiles: () => analyzeFiles(options.extensions, options.ignore), dryRun: options.dryRun, repoName: options.repoName }), renderOptions);
8
8
  }
9
9
  export function analyzeCommand() {
10
10
  const command = new Command();
@@ -12,8 +12,9 @@ export function analyzeCommand() {
12
12
  .command("analyze")
13
13
  .description("Analyze your codebase to determine component usage metrics")
14
14
  .addOption(new Option("-e, --extensions [ext]", "file extensions to include when searching for components").default("**/*.{js,jsx,ts,tsx}", "glob pattern to determine file extensions"))
15
- .addOption(new Option("-i, --ignore", "files to ignore when searching for components").default("**/*.{test,spec}.*", "glob pattern to determine ignored files"))
15
+ .addOption(new Option("-i, --ignore [ext]", "files to ignore when searching for components").default("**/*.{test,spec}.*", "glob pattern to determine ignored files"))
16
16
  .addOption(new Option("-d, --dry-run", "don't push results to zeroheight").default(false))
17
+ .addOption(new Option("-r, --repo-name <string>", "name of the repository"))
17
18
  .action(async (options) => {
18
19
  try {
19
20
  await analyzeAction(options);
@@ -1,5 +1,4 @@
1
1
  import { RawUsageMap } from "./analyze.js";
2
- import { Credentials } from "../common/config.js";
3
2
  export interface ComponentUsageRecord {
4
3
  name: string;
5
4
  files: string[];
@@ -15,11 +14,6 @@ export interface ComponentUsageRecord {
15
14
  * @returns list of file paths
16
15
  */
17
16
  export declare function findFiles(base: string, extensions: string, ignorePattern: string): Promise<string[]>;
18
- export declare function getZeroheightURL(): URL;
19
- /**
20
- * Post usage data to zeroheight to store
21
- */
22
- export declare function submitUsageData(usage: RawUsageMap, credentials: Credentials): Promise<any>;
23
17
  export declare function analyzeFiles(extensions: string, ignorePattern: string): Promise<{
24
18
  errors: string[];
25
19
  usage: RawUsageMap;
@@ -46,61 +46,6 @@ async function getGitIgnore(base) {
46
46
  return "";
47
47
  }
48
48
  }
49
- export function getZeroheightURL() {
50
- if (process.env["NODE_ENV"] === "dev") {
51
- return new URL("https://zeroheight.dev");
52
- }
53
- else {
54
- return new URL("https://zeroheight.com");
55
- }
56
- }
57
- /**
58
- * Post usage data to zeroheight to store
59
- */
60
- export async function submitUsageData(usage, credentials) {
61
- const baseURL = getZeroheightURL();
62
- baseURL.pathname = "/open_api/v1/component_usages";
63
- if (process.env["NODE_ENV"] === "dev") {
64
- process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
65
- }
66
- const response = await fetch(baseURL, {
67
- method: "POST",
68
- headers: {
69
- "Content-Type": "application/json",
70
- "X-API-CLIENT": credentials.client,
71
- "X-API-KEY": credentials.token,
72
- },
73
- body: JSON.stringify({
74
- component_usage: {
75
- usage: transformUsageByName(usage),
76
- },
77
- }),
78
- });
79
- if (response.status === 401) {
80
- throw new Error("Unauthorized");
81
- }
82
- return await response.json();
83
- }
84
- /**
85
- * Transform usage map grouped by file into usage map grouped by component
86
- */
87
- function transformUsageByName(usage) {
88
- const transformedUsageMap = new Map();
89
- for (const [file, rawUsage] of usage) {
90
- for (const { count, package: packageName, name } of rawUsage) {
91
- const key = `package:${packageName} name:${name}`;
92
- const currentValue = transformedUsageMap.get(key);
93
- const newFileList = [...(currentValue?.files ?? []), file]; // Add file to list
94
- transformedUsageMap.set(key, {
95
- name: currentValue?.name ?? name,
96
- files: Array.from(new Set(newFileList)), // Ensure unique file paths
97
- count: count + (currentValue?.count ?? 0),
98
- package: packageName,
99
- });
100
- }
101
- }
102
- return Array.from(transformedUsageMap.values());
103
- }
104
49
  export async function analyzeFiles(extensions, ignorePattern) {
105
50
  const files = await findFiles(process.cwd(), extensions, ignorePattern);
106
51
  if (files.length === 0) {
@@ -11,7 +11,10 @@ export async function authAction(options, renderOptions) {
11
11
  const { waitUntilExit } = render(React.createElement(CredentialsAlreadyExists, { existingConfig: existingConfig, token: options.token, client: options.client, configPath: configPath(), onOverwriteCredentials: async () => {
12
12
  const newClient = options.client ?? existingConfig.client;
13
13
  const newToken = options.token ?? existingConfig.token;
14
- await writeConfig(newClient, newToken);
14
+ await writeConfig({
15
+ client: newClient,
16
+ token: newToken,
17
+ });
15
18
  render(React.createElement(Box, { flexDirection: "column" },
16
19
  React.createElement(Text, { color: "green" }, "Updated credentials"),
17
20
  React.createElement(CredentialsPreview, { client: newClient, token: newToken })));
@@ -21,7 +24,10 @@ export async function authAction(options, renderOptions) {
21
24
  else {
22
25
  const { waitUntilExit, rerender } = render(React.createElement(NoCredentialsOnboarding, { configPath: configPath(), onSaveCredentials: async (persist, newClient, newToken) => {
23
26
  if (persist) {
24
- await writeConfig(newClient, newToken);
27
+ await writeConfig({
28
+ client: newClient,
29
+ token: newToken,
30
+ });
25
31
  rerender(React.createElement(Box, { flexDirection: "column" },
26
32
  React.createElement(Text, { color: "green" }, "Updated credentials"),
27
33
  React.createElement(CredentialsPreview, { client: newClient, token: newToken })));
@@ -0,0 +1,5 @@
1
+ import { RawUsageMap } from "../commands/analyze.js";
2
+ import { Credentials } from "./config.js";
3
+ export declare function getZeroheightURL(): URL;
4
+ export declare function submitUsageData(usage: RawUsageMap, repoName: string, credentials: Credentials): Promise<any>;
5
+ export declare function getExistingRepoNames(credentials: Credentials): Promise<any>;
@@ -0,0 +1,67 @@
1
+ export function getZeroheightURL() {
2
+ if (process.env["NODE_ENV"] === "dev") {
3
+ return new URL("https://zeroheight.dev");
4
+ }
5
+ else {
6
+ return new URL("https://zeroheight.com");
7
+ }
8
+ }
9
+ export async function submitUsageData(usage, repoName, credentials) {
10
+ return post("/open_api/v1/component_usages", {
11
+ repo_name: repoName,
12
+ usage: transformUsageByName(usage),
13
+ }, credentials);
14
+ }
15
+ export function getExistingRepoNames(credentials) {
16
+ return get("/open_api/v1/component_usages/repo_names", credentials);
17
+ }
18
+ async function get(path, credentials) {
19
+ return request(path, credentials, {
20
+ method: "GET",
21
+ });
22
+ }
23
+ async function post(path, body, credentials) {
24
+ return request(path, credentials, {
25
+ method: "POST",
26
+ body: JSON.stringify(body),
27
+ });
28
+ }
29
+ async function request(path, credentials, init) {
30
+ const url = getZeroheightURL();
31
+ url.pathname = path;
32
+ if (process.env["NODE_ENV"] === "dev") {
33
+ process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
34
+ }
35
+ const response = await fetch(url, {
36
+ ...init,
37
+ headers: {
38
+ "X-API-CLIENT": credentials.client,
39
+ "X-API-KEY": credentials.token,
40
+ "Content-Type": "application/json",
41
+ },
42
+ });
43
+ if (response.status === 401) {
44
+ throw new Error("Unauthorized");
45
+ }
46
+ return await response.json();
47
+ }
48
+ /**
49
+ * Transform usage map grouped by file into usage map grouped by component
50
+ */
51
+ function transformUsageByName(usage) {
52
+ const transformedUsageMap = new Map();
53
+ for (const [file, rawUsage] of usage) {
54
+ for (const { count, package: packageName, name } of rawUsage) {
55
+ const key = `package:${packageName} name:${name}`;
56
+ const currentValue = transformedUsageMap.get(key);
57
+ const newFileList = [...(currentValue?.files ?? []), file]; // Add file to list
58
+ transformedUsageMap.set(key, {
59
+ name: currentValue?.name ?? name,
60
+ files: Array.from(new Set(newFileList)), // Ensure unique file paths
61
+ count: count + (currentValue?.count ?? 0),
62
+ package: packageName,
63
+ });
64
+ }
65
+ }
66
+ return Array.from(transformedUsageMap.values());
67
+ }
@@ -2,10 +2,13 @@ export interface Credentials {
2
2
  token: string;
3
3
  client: string;
4
4
  }
5
+ export interface Config extends Credentials {
6
+ repoNames: Record<string, string>;
7
+ }
5
8
  export declare function configPath(): string;
6
9
  /**
7
10
  * Read the credentials from the user's home directory
8
11
  * @returns access token and client id or null
9
12
  */
10
- export declare function readConfig(): Promise<Credentials | null>;
11
- export declare function writeConfig(client: string, token: string): Promise<void>;
13
+ export declare function readConfig(): Promise<Config | null>;
14
+ export declare function writeConfig(newValues: Partial<Config>): Promise<void>;
@@ -11,22 +11,31 @@ export function configPath() {
11
11
  export async function readConfig() {
12
12
  try {
13
13
  const rawData = await readFile(configPath(), "utf8");
14
- const { token, client } = JSON.parse(rawData);
15
- return { token, client };
14
+ const { token, client, repoNames = {} } = JSON.parse(rawData);
15
+ return { token, client, repoNames };
16
16
  }
17
17
  catch (e) {
18
18
  const client = process.env["ZEROHEIGHT_CLIENT_ID"];
19
19
  const token = process.env["ZEROHEIGHT_ACCESS_TOKEN"];
20
20
  if (client && token) {
21
- return { token, client };
21
+ return { token, client, repoNames: {} };
22
22
  }
23
23
  return null;
24
24
  }
25
25
  }
26
- export async function writeConfig(client, token) {
26
+ export async function writeConfig(newValues) {
27
+ const config = (await readConfig()) ??
28
+ {
29
+ repoNames: {},
30
+ };
31
+ // TODO: if we have a more complex config, we should use a deep merge here
27
32
  const payload = JSON.stringify({
28
- client: client,
29
- token: token,
33
+ ...config,
34
+ ...newValues,
35
+ repoNames: {
36
+ ...config.repoNames,
37
+ ...(newValues.repoNames ?? {}),
38
+ },
30
39
  });
31
40
  return writeFile(configPath(), payload);
32
41
  }
@@ -6,5 +6,6 @@ export interface AnalyzeProps {
6
6
  usage: RawUsageMap;
7
7
  }>;
8
8
  dryRun: boolean;
9
+ repoName?: string;
9
10
  }
10
- export default function Analyze({ onAnalyzeFiles, dryRun }: AnalyzeProps): React.JSX.Element;
11
+ export default function Analyze({ onAnalyzeFiles, dryRun, repoName, }: AnalyzeProps): React.JSX.Element;
@@ -4,16 +4,19 @@ import { Box, Newline, Text, useApp } from "ink";
4
4
  import { configPath, writeConfig, readConfig, } from "../../common/config.js";
5
5
  import UsageTable from "../usage-table.js";
6
6
  import ContinuePrompt from "../ui/continue-prompt.js";
7
- import { getZeroheightURL, submitUsageData, } from "../../commands/analyze.utils.js";
8
7
  import Link from "ink-link";
9
8
  import NoCredentialsOnboarding from "../auth/no-credentials-onboarding.js";
10
- export default function Analyze({ onAnalyzeFiles, dryRun }) {
9
+ import { getZeroheightURL, submitUsageData } from "../../common/api.js";
10
+ import RepoNamePrompt from "../repo-name-prompt.js";
11
+ export default function Analyze({ onAnalyzeFiles, dryRun, repoName, }) {
11
12
  const { exit } = useApp();
12
13
  const [usageResult, setUsageResult] = React.useState(null);
13
14
  const [isSendingData, setIsSendingData] = React.useState(false);
14
15
  const [errorList, setErrorList] = React.useState([]);
15
16
  const [credentials, setCredentials] = React.useState(null);
16
17
  const [resourceURL, setResourceURL] = React.useState(null);
18
+ const [repo, setRepo] = React.useState(repoName);
19
+ const [promptRepo, setPromptRepo] = React.useState(repoName === undefined);
17
20
  React.useEffect(() => {
18
21
  onAnalyzeFiles()
19
22
  .then(({ errors, usage }) => {
@@ -22,15 +25,23 @@ export default function Analyze({ onAnalyzeFiles, dryRun }) {
22
25
  })
23
26
  .catch((e) => setErrorList((s) => [...s, e]));
24
27
  if (!dryRun) {
25
- readConfig().then((newCredentials) => setCredentials(newCredentials));
28
+ readConfig().then((config) => {
29
+ if (config) {
30
+ setCredentials({ token: config.token, client: config.client });
31
+ }
32
+ });
26
33
  }
27
34
  }, []);
28
35
  async function handleContinue(shouldContinue) {
29
36
  if (shouldContinue) {
30
- if (!dryRun && credentials && usageResult) {
37
+ if (!dryRun && credentials && usageResult && !repo) {
38
+ setPromptRepo(true);
39
+ setRepo("");
40
+ }
41
+ else if (!dryRun && credentials && usageResult && repo) {
31
42
  setIsSendingData(true);
32
43
  try {
33
- await submitUsageData(usageResult, credentials);
44
+ await submitUsageData(usageResult, repo, credentials);
34
45
  const resourceURL = getZeroheightURL();
35
46
  resourceURL.pathname = "/adoption/";
36
47
  setResourceURL(resourceURL);
@@ -47,6 +58,15 @@ export default function Analyze({ onAnalyzeFiles, dryRun }) {
47
58
  exit();
48
59
  }
49
60
  }
61
+ function handleOnRepoNameSelected(repoName) {
62
+ setRepo(repoName);
63
+ writeConfig({
64
+ repoNames: {
65
+ [process.cwd()]: repoName,
66
+ },
67
+ });
68
+ setPromptRepo(false);
69
+ }
50
70
  if (errorList.length > 0) {
51
71
  return (React.createElement(Box, { flexDirection: "column" },
52
72
  React.createElement(Text, { color: "red" }, "Error:"),
@@ -59,7 +79,10 @@ export default function Analyze({ onAnalyzeFiles, dryRun }) {
59
79
  const newCredentials = { token: newToken, client: newClient };
60
80
  setCredentials(newCredentials);
61
81
  if (persist) {
62
- await writeConfig(newClient, newToken);
82
+ await writeConfig({
83
+ client: newClient,
84
+ token: newToken,
85
+ });
63
86
  }
64
87
  } }));
65
88
  }
@@ -91,7 +114,7 @@ export default function Analyze({ onAnalyzeFiles, dryRun }) {
91
114
  " ",
92
115
  React.createElement(Link, { url: resourceURL.toString() }, "usage data on zeroheight"))));
93
116
  }
94
- if (usageResult) {
117
+ if (usageResult && !promptRepo) {
95
118
  return (React.createElement(Box, { flexDirection: "column" },
96
119
  React.createElement(UsageTable, { usage: usageResult }),
97
120
  dryRun && (React.createElement(React.Fragment, null,
@@ -103,5 +126,8 @@ export default function Analyze({ onAnalyzeFiles, dryRun }) {
103
126
  React.createElement(Newline, null))),
104
127
  !dryRun && React.createElement(ContinuePrompt, { onContinue: handleContinue })));
105
128
  }
129
+ if (promptRepo) {
130
+ return (React.createElement(RepoNamePrompt, { credentials: credentials, onRepoNameSelected: handleOnRepoNameSelected }));
131
+ }
106
132
  return React.createElement(Text, null, "NOPE");
107
133
  }
@@ -0,0 +1,8 @@
1
+ import React from "react";
2
+ import { Credentials } from "../common/config.js";
3
+ interface RepoNamePromptProps {
4
+ credentials: Credentials | null;
5
+ onRepoNameSelected: (repoName: string) => void;
6
+ }
7
+ export default function RepoNamePrompt({ credentials, onRepoNameSelected, }: RepoNamePromptProps): React.JSX.Element | null;
8
+ export {};
@@ -0,0 +1,93 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import SelectInput from "ink-select-input";
5
+ import { readConfig } from "../common/config.js";
6
+ import { getExistingRepoNames } from "../common/api.js";
7
+ import TextInput from "ink-text-input";
8
+ var RepoNamePromptState;
9
+ (function (RepoNamePromptState) {
10
+ RepoNamePromptState["LOADING"] = "LOADING";
11
+ RepoNamePromptState["ERROR"] = "ERROR";
12
+ RepoNamePromptState["SELECT_REPO_NAME"] = "SELECT_REPO_NAME";
13
+ RepoNamePromptState["ENTER_REPO_NAME"] = "ENTER_REPO_NAME";
14
+ })(RepoNamePromptState || (RepoNamePromptState = {}));
15
+ export default function RepoNamePrompt({ credentials, onRepoNameSelected, }) {
16
+ const [state, setState] = React.useState(RepoNamePromptState.LOADING);
17
+ const [repoNames, setRepoNames] = React.useState([]);
18
+ const [repoName, setRepoName] = React.useState("");
19
+ async function loadRepoNames(credentials) {
20
+ try {
21
+ const names = await getExistingRepoNames(credentials);
22
+ setRepoNames(names);
23
+ if (names.length === 0) {
24
+ setState(RepoNamePromptState.ENTER_REPO_NAME);
25
+ }
26
+ else {
27
+ setState(RepoNamePromptState.SELECT_REPO_NAME);
28
+ }
29
+ }
30
+ catch (e) {
31
+ setState(RepoNamePromptState.ERROR);
32
+ }
33
+ }
34
+ React.useEffect(() => {
35
+ if (!credentials) {
36
+ return;
37
+ }
38
+ setState(RepoNamePromptState.LOADING);
39
+ readConfig().then((config) => {
40
+ const repoName = config?.repoNames?.[process.cwd()];
41
+ if (repoName) {
42
+ onRepoNameSelected(repoName);
43
+ }
44
+ else {
45
+ loadRepoNames(credentials);
46
+ }
47
+ });
48
+ }, [credentials]);
49
+ function onSelect(item) {
50
+ if (item.value === "enter-repository-name") {
51
+ setState(RepoNamePromptState.ENTER_REPO_NAME);
52
+ return;
53
+ }
54
+ onRepoNameSelected(item.value);
55
+ }
56
+ if (state === RepoNamePromptState.LOADING) {
57
+ return (React.createElement(Box, { flexDirection: "column" },
58
+ React.createElement(Text, null,
59
+ React.createElement(Text, { color: "cyan" },
60
+ React.createElement(Spinner, { type: "dots" })),
61
+ " ",
62
+ "Fetching repository names...")));
63
+ }
64
+ if (state === RepoNamePromptState.ERROR) {
65
+ return (React.createElement(Box, { flexDirection: "column" },
66
+ React.createElement(Text, { color: "red" }, "Error:"),
67
+ React.createElement(Box, { flexDirection: "column", marginLeft: 1 },
68
+ React.createElement(Text, null, "- Failed to fetch repository names"))));
69
+ }
70
+ if (state === RepoNamePromptState.SELECT_REPO_NAME) {
71
+ const selectItems = [
72
+ { label: "Enter repository name", value: "enter-repository-name" },
73
+ ...repoNames.map((name) => ({
74
+ label: name,
75
+ value: name,
76
+ })),
77
+ ];
78
+ return (React.createElement(Box, { flexDirection: "column" },
79
+ React.createElement(Text, null, "Select the repository name"),
80
+ React.createElement(SelectInput, { items: selectItems, onSelect: onSelect })));
81
+ }
82
+ if (state === RepoNamePromptState.ENTER_REPO_NAME) {
83
+ return (React.createElement(Box, null,
84
+ React.createElement(Text, null, "Repository name:"),
85
+ React.createElement(TextInput, { value: repoName, onChange: setRepoName, onSubmit: (value) => {
86
+ if (value.trim().length === 0) {
87
+ return;
88
+ }
89
+ onRepoNameSelected(value);
90
+ } })));
91
+ }
92
+ return null;
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeroheight/adoption-cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "license": "ISC",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {
@@ -45,6 +45,7 @@
45
45
  "glob": "^10.3.15",
46
46
  "ink-link": "^3.0.0",
47
47
  "ink-render-string": "^1.0.0",
48
+ "ink-select-input": "^5.0.0",
48
49
  "ink-spinner": "^5.0.0",
49
50
  "ink-testing-library": "git+ssh://git@github.com/vadimdemedes/ink-testing-library.git#f44b077e9a05a1d615bab41c72906726d34ea085",
50
51
  "ink-text-input": "^5.0.1",
@@ -58,13 +59,9 @@
58
59
  },
59
60
  "types": "./dist/cli.d.ts",
60
61
  "description": "CLI for measuring component usage",
61
- "repository": {
62
- "type": "git",
63
- "url": "git+https://github.com/zeroheight/zh-measure-cli.git"
64
- },
65
62
  "author": "zeroheight",
66
63
  "bugs": {
67
- "url": "https://github.com/zeroheight/zh-measure-cli/issues"
64
+ "email": "support@zeroheight.com"
68
65
  },
69
- "homepage": "https://github.com/zeroheight/zh-measure-cli#readme"
66
+ "homepage": "https://zeroheight.com"
70
67
  }