@zeroheight/adoption-cli 0.2.5 → 0.2.6

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 (36) hide show
  1. package/README.md +4 -1
  2. package/dist/ast/analyze.d.ts +14 -0
  3. package/dist/ast/analyze.js +91 -0
  4. package/dist/ast/parser.d.ts +11 -0
  5. package/dist/ast/parser.js +23 -0
  6. package/dist/cli.d.ts +4 -0
  7. package/dist/cli.js +22 -0
  8. package/dist/commands/analyze.d.ts +13 -0
  9. package/dist/commands/analyze.js +27 -0
  10. package/dist/commands/analyze.utils.d.ts +20 -0
  11. package/dist/commands/analyze.utils.js +71 -0
  12. package/dist/commands/auth.d.ts +9 -0
  13. package/dist/commands/auth.js +57 -0
  14. package/dist/common/api.d.ts +5 -0
  15. package/dist/common/api.js +67 -0
  16. package/dist/common/config.d.ts +14 -0
  17. package/dist/common/config.js +41 -0
  18. package/dist/components/analyze/analyze.d.ts +11 -0
  19. package/dist/components/analyze/analyze.js +136 -0
  20. package/dist/components/auth/credentials-already-exists.d.ts +16 -0
  21. package/dist/components/auth/credentials-already-exists.js +31 -0
  22. package/dist/components/auth/credentials-preview.d.ts +10 -0
  23. package/dist/components/auth/credentials-preview.js +20 -0
  24. package/dist/components/auth/no-credentials-onboarding.d.ts +7 -0
  25. package/dist/components/auth/no-credentials-onboarding.js +45 -0
  26. package/dist/components/help-info.d.ts +5 -0
  27. package/dist/components/help-info.js +24 -0
  28. package/dist/components/repo-name-prompt.d.ts +8 -0
  29. package/dist/components/repo-name-prompt.js +93 -0
  30. package/dist/components/ui/confirm-input.d.ts +10 -0
  31. package/dist/components/ui/confirm-input.js +10 -0
  32. package/dist/components/ui/continue-prompt.d.ts +6 -0
  33. package/dist/components/ui/continue-prompt.js +13 -0
  34. package/dist/components/usage-table.d.ts +7 -0
  35. package/dist/components/usage-table.js +14 -0
  36. package/package.json +4 -4
package/README.md CHANGED
@@ -9,7 +9,9 @@ npm i @zeroheight/adoption-cli
9
9
  ```
10
10
 
11
11
  ## Usage
12
+
12
13
  In the repository in which you wish to analyze the component usage, run the following command:
14
+
13
15
  ```
14
16
  zh-adoption analyze
15
17
  ```
@@ -21,6 +23,7 @@ zh-adoption auth
21
23
  ```
22
24
 
23
25
  More info on the commands can be seen by running
26
+
24
27
  ```
25
28
  zh-adoption --help
26
- ```
29
+ ```
@@ -0,0 +1,14 @@
1
+ import type { Node } from "acorn";
2
+ export type VisitorState = {
3
+ components: Map<string, number>;
4
+ imports: Map<string, {
5
+ package: string;
6
+ name: string;
7
+ }>;
8
+ };
9
+ export type RawUsage = {
10
+ name: string;
11
+ count: number;
12
+ package: string;
13
+ };
14
+ export declare function analyze(ast: Node): RawUsage[];
@@ -0,0 +1,91 @@
1
+ import * as walk from "acorn-walk";
2
+ function transformVisitorStateToRawUsage(visitorState) {
3
+ const transformedUsageMap = new Map();
4
+ for (const [name, count] of visitorState.components) {
5
+ const importInfo = visitorState.imports.get(name);
6
+ const actualName = importInfo?.name ?? name;
7
+ const packageName = importInfo?.package ?? "";
8
+ const key = `package:${packageName} name:${actualName}`;
9
+ const currentValue = transformedUsageMap.get(key) ?? {
10
+ name: actualName,
11
+ count: 0,
12
+ package: importInfo?.package ?? "",
13
+ };
14
+ transformedUsageMap.set(key, {
15
+ ...currentValue,
16
+ count: currentValue.count + count,
17
+ });
18
+ }
19
+ return Array.from(transformedUsageMap.values());
20
+ }
21
+ export function analyze(ast) {
22
+ const visitorState = {
23
+ components: new Map(),
24
+ imports: new Map(),
25
+ };
26
+ const visitors = {
27
+ JSXElement(node, state, _nodePath) {
28
+ const el = node.openingElement;
29
+ const elName = el.name.name;
30
+ if (!elName)
31
+ return;
32
+ // Ignore html tags e.g. div, h1, span
33
+ if (elName[0] === elName[0]?.toLocaleLowerCase())
34
+ return;
35
+ state.components.set(elName, (state.components.get(elName) ?? 0) + 1);
36
+ },
37
+ ImportDeclaration(node, state, _nodePath) {
38
+ const packageName = node.source.value;
39
+ node.specifiers.forEach((specifier) => {
40
+ // not handling namespace imports as we can't determine the actual name
41
+ // e.g. import * as MyIconLibrary from 'my-icon-library'
42
+ if (specifier.type === "ImportNamespaceSpecifier") {
43
+ return;
44
+ }
45
+ let name = specifier.local.name;
46
+ if (specifier.type === "ImportDefaultSpecifier") {
47
+ // handles the following cases:
48
+ // import Icon from 'my-icon-library'
49
+ name = specifier.local.name;
50
+ }
51
+ else if (specifier.type === "ImportSpecifier") {
52
+ // handles the following cases:
53
+ // import { Icon } from 'my-icon-library'
54
+ // import { Icon as MyIcon } from 'my-icon-library'
55
+ name =
56
+ specifier.imported.type === "Literal"
57
+ ? specifier.imported.value
58
+ : specifier.imported.name;
59
+ }
60
+ state.imports.set(specifier.local.name, {
61
+ package: packageName,
62
+ name,
63
+ });
64
+ });
65
+ },
66
+ };
67
+ const base = {
68
+ ...walk.base,
69
+ TSInterfaceDeclaration() { },
70
+ TSModuleDeclaration() { },
71
+ TSAsExpression() { },
72
+ TSDeclareFunction() { },
73
+ TSTypeAliasDeclaration() { },
74
+ TSNonNullExpression() { },
75
+ TSEnumDeclaration() { },
76
+ TSParameterProperty() { },
77
+ TSImportEqualsDeclaration() { },
78
+ TSInstantiationExpression() { },
79
+ TSDeclareMethod() { },
80
+ };
81
+ try {
82
+ walk.ancestor(ast, visitors, base, visitorState);
83
+ }
84
+ catch (e) {
85
+ // We don't want to log the error to the users - maybe could add some actually logging in the future
86
+ // console.log("error", e);
87
+ }
88
+ finally {
89
+ return transformVisitorStateToRawUsage(visitorState);
90
+ }
91
+ }
@@ -0,0 +1,11 @@
1
+ import * as acorn from "acorn";
2
+ /**
3
+ * Parse code and return AST
4
+ *
5
+ * This extends the acorn parser with support for TypeScript and JSX
6
+ *
7
+ * @param code The string containing JS/TS
8
+ * @param version a version of ECMA Script to use, defaults to latest
9
+ * @returns Parsed AST
10
+ */
11
+ export declare function parse(code: string, version?: acorn.ecmaVersion): acorn.Program;
@@ -0,0 +1,23 @@
1
+ import * as acorn from "acorn";
2
+ import { tsPlugin } from "acorn-typescript";
3
+ import * as walk from "acorn-walk";
4
+ import { extend } from "acorn-jsx-walk";
5
+ /**
6
+ * Parse code and return AST
7
+ *
8
+ * This extends the acorn parser with support for TypeScript and JSX
9
+ *
10
+ * @param code The string containing JS/TS
11
+ * @param version a version of ECMA Script to use, defaults to latest
12
+ * @returns Parsed AST
13
+ */
14
+ export function parse(code, version = "latest") {
15
+ const typescriptPlugin = tsPlugin();
16
+ extend(walk.base);
17
+ const JSXParser = acorn.Parser.extend(typescriptPlugin);
18
+ return JSXParser.parse(code, {
19
+ ecmaVersion: version,
20
+ sourceType: "module",
21
+ locations: true,
22
+ });
23
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ declare const program: Command;
4
+ export default program;
package/dist/cli.js ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import * as React from "react";
3
+ import { Command } from "commander";
4
+ import { render } from "ink-render-string";
5
+ import { analyzeCommand } from "./commands/analyze.js";
6
+ import { authCommand } from "./commands/auth.js";
7
+ import HelpInfo from "./components/help-info.js";
8
+ const program = new Command();
9
+ const { output, cleanup } = render(React.createElement(HelpInfo, null));
10
+ program
11
+ .name("zh-adoption")
12
+ .description("CLI for measuring design system usage usage in your products")
13
+ .version("0.1.0")
14
+ .addHelpText("before", output)
15
+ .addCommand(analyzeCommand())
16
+ .addCommand(authCommand());
17
+ cleanup();
18
+ // Only start parsing if run as CLI, don't start parsing during testing
19
+ if (process.env["NODE_ENV"] !== "test") {
20
+ program.parse();
21
+ }
22
+ export default program;
@@ -0,0 +1,13 @@
1
+ import { RenderOptions } from "ink";
2
+ import { Command } from "commander";
3
+ import { RawUsage } from "../ast/analyze.js";
4
+ interface AnalyzeOptions {
5
+ extensions: string;
6
+ ignore: string;
7
+ dryRun: boolean;
8
+ repoName?: string;
9
+ }
10
+ export type RawUsageMap = Map<string, RawUsage[]>;
11
+ export declare function analyzeAction(options: AnalyzeOptions, renderOptions?: RenderOptions): Promise<void>;
12
+ export declare function analyzeCommand(): Command;
13
+ export {};
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { render } from "ink";
3
+ import { Command, Option } from "commander";
4
+ import Analyze from "../components/analyze/analyze.js";
5
+ import { analyzeFiles } from "./analyze.utils.js";
6
+ export async function analyzeAction(options, renderOptions) {
7
+ render(React.createElement(Analyze, { onAnalyzeFiles: () => analyzeFiles(options.extensions, options.ignore), dryRun: options.dryRun, repoName: options.repoName }), renderOptions);
8
+ }
9
+ export function analyzeCommand() {
10
+ const command = new Command();
11
+ return command
12
+ .command("analyze")
13
+ .description("Analyze your codebase to determine component usage metrics")
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 [ext]", "files to ignore when searching for components").default("**/*.{test,spec}.*", "glob pattern to determine ignored files"))
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"))
18
+ .action(async (options) => {
19
+ try {
20
+ await analyzeAction(options);
21
+ }
22
+ catch (e) {
23
+ console.error(e);
24
+ process.exitCode = 1;
25
+ }
26
+ });
27
+ }
@@ -0,0 +1,20 @@
1
+ import { RawUsageMap } from "./analyze.js";
2
+ export interface ComponentUsageRecord {
3
+ name: string;
4
+ files: string[];
5
+ count: number;
6
+ package: string;
7
+ }
8
+ /**
9
+ * Get a list of files matching extensions, skips hidden files and node_modules
10
+ * @param base starting directory
11
+ * @param extensions glob syntax of files to look for, e.g. **\/*.{js,jsx,ts,tsx}
12
+ * @param ignorePattern glob syntax of files to ignore, e.g. **\/*.{test,spec}.*
13
+ * @param gitIgnore contents of .gitignore file
14
+ * @returns list of file paths
15
+ */
16
+ export declare function findFiles(base: string, extensions: string, ignorePattern: string): Promise<string[]>;
17
+ export declare function analyzeFiles(extensions: string, ignorePattern: string): Promise<{
18
+ errors: string[];
19
+ usage: RawUsageMap;
20
+ }>;
@@ -0,0 +1,71 @@
1
+ import { stat, readFile } from "fs/promises";
2
+ import * as fs from "fs";
3
+ import path from "path";
4
+ import { Glob } from "glob";
5
+ import ignore from "ignore";
6
+ import { parse } from "../ast/parser.js";
7
+ import { analyze } from "../ast/analyze.js";
8
+ /**
9
+ * Get a list of files matching extensions, skips hidden files and node_modules
10
+ * @param base starting directory
11
+ * @param extensions glob syntax of files to look for, e.g. **\/*.{js,jsx,ts,tsx}
12
+ * @param ignorePattern glob syntax of files to ignore, e.g. **\/*.{test,spec}.*
13
+ * @param gitIgnore contents of .gitignore file
14
+ * @returns list of file paths
15
+ */
16
+ export async function findFiles(base, extensions, ignorePattern) {
17
+ const gitIgnore = await getGitIgnore(base);
18
+ // typescript complains about the ignore() function having
19
+ // no call signature. Could not find a way to fix this.
20
+ // @ts-ignore
21
+ const ig = ignore()
22
+ // skip hidden files and directories
23
+ .add("/**/.*")
24
+ .add("/**/.*/")
25
+ // skip node_modules
26
+ .add("node_modules/")
27
+ // add gitignore rules
28
+ .add(gitIgnore);
29
+ const matchingFiles = [];
30
+ const g = new Glob(extensions, { cwd: base, ignore: ignorePattern, fs });
31
+ for await (const file of g) {
32
+ const fullFilepath = path.join(base, file);
33
+ const meta = await stat(fullFilepath);
34
+ if (!meta.isDirectory() && !ig.ignores(file)) {
35
+ matchingFiles.push(fullFilepath);
36
+ }
37
+ }
38
+ return matchingFiles;
39
+ }
40
+ async function getGitIgnore(base) {
41
+ const gitIgnorePath = path.join(base, ".gitignore");
42
+ try {
43
+ return await readFile(gitIgnorePath, "utf-8");
44
+ }
45
+ catch (e) {
46
+ return "";
47
+ }
48
+ }
49
+ export async function analyzeFiles(extensions, ignorePattern) {
50
+ const files = await findFiles(process.cwd(), extensions, ignorePattern);
51
+ if (files.length === 0) {
52
+ throw new Error("Can't find any relevant files");
53
+ }
54
+ const parseErrors = [];
55
+ const usageMap = new Map();
56
+ for (const file of files) {
57
+ try {
58
+ const fileContents = await readFile(file, "utf-8");
59
+ const ast = parse(fileContents);
60
+ const usage = analyze(ast);
61
+ if (usage.length > 0) {
62
+ const relativePath = file.slice(process.cwd().length);
63
+ usageMap.set(relativePath, usage);
64
+ }
65
+ }
66
+ catch (e) {
67
+ parseErrors.push(`Can't parse file ${file}`);
68
+ }
69
+ }
70
+ return { errors: parseErrors, usage: usageMap };
71
+ }
@@ -0,0 +1,9 @@
1
+ import { Command } from "commander";
2
+ import { RenderOptions } from "ink";
3
+ interface AuthOptions {
4
+ client?: string;
5
+ token?: string;
6
+ }
7
+ export declare function authAction(options: AuthOptions, renderOptions?: RenderOptions): Promise<void>;
8
+ export declare function authCommand(): Command;
9
+ export {};
@@ -0,0 +1,57 @@
1
+ import * as React from "react";
2
+ import { Command } from "commander";
3
+ import { Box, Text, render } from "ink";
4
+ import CredentialsAlreadyExists from "../components/auth/credentials-already-exists.js";
5
+ import CredentialsPreview from "../components/auth/credentials-preview.js";
6
+ import NoCredentialsOnboarding from "../components/auth/no-credentials-onboarding.js";
7
+ import { configPath, readConfig, writeConfig } from "../common/config.js";
8
+ export async function authAction(options, renderOptions) {
9
+ const existingConfig = await readConfig();
10
+ if (existingConfig) {
11
+ const { waitUntilExit } = render(React.createElement(CredentialsAlreadyExists, { existingConfig: existingConfig, token: options.token, client: options.client, configPath: configPath(), onOverwriteCredentials: async () => {
12
+ const newClient = options.client ?? existingConfig.client;
13
+ const newToken = options.token ?? existingConfig.token;
14
+ await writeConfig({
15
+ client: newClient,
16
+ token: newToken,
17
+ });
18
+ render(React.createElement(Box, { flexDirection: "column" },
19
+ React.createElement(Text, { color: "green" }, "Updated credentials"),
20
+ React.createElement(CredentialsPreview, { client: newClient, token: newToken })));
21
+ } }), renderOptions);
22
+ await waitUntilExit();
23
+ }
24
+ else {
25
+ const { waitUntilExit, rerender } = render(React.createElement(NoCredentialsOnboarding, { configPath: configPath(), onSaveCredentials: async (persist, newClient, newToken) => {
26
+ if (persist) {
27
+ await writeConfig({
28
+ client: newClient,
29
+ token: newToken,
30
+ });
31
+ rerender(React.createElement(Box, { flexDirection: "column" },
32
+ React.createElement(Text, { color: "green" }, "Updated credentials"),
33
+ React.createElement(CredentialsPreview, { client: newClient, token: newToken })));
34
+ }
35
+ } }));
36
+ await waitUntilExit();
37
+ }
38
+ render(React.createElement(Text, { bold: true }, "Done"));
39
+ }
40
+ export function authCommand() {
41
+ const command = new Command();
42
+ return command
43
+ .command("auth")
44
+ .description("Authenticate with zeroheight")
45
+ .addHelpText("before", "Set credentials for performing actions with zeroheight. Credentials will default to ZEROHEIGHT_CLIENT_ID and ZEROHEIGHT_ACCESS_TOKEN environment variables if not supplied")
46
+ .option("-c, --client <client_id>", "zeroheight Client ID", process.env["ZEROHEIGHT_CLIENT_ID"])
47
+ .option("-t, --token <access_token>", "zeroheight Access Token", process.env["ZEROHEIGHT_ACCESS_TOKEN"])
48
+ .action(async (options) => {
49
+ try {
50
+ await authAction(options);
51
+ }
52
+ catch (e) {
53
+ console.error(e);
54
+ process.exitCode = 1;
55
+ }
56
+ });
57
+ }
@@ -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
+ }
@@ -0,0 +1,14 @@
1
+ export interface Credentials {
2
+ token: string;
3
+ client: string;
4
+ }
5
+ export interface Config extends Credentials {
6
+ repoNames: Record<string, string>;
7
+ }
8
+ export declare function configPath(): string;
9
+ /**
10
+ * Read the credentials from the user's home directory
11
+ * @returns access token and client id or null
12
+ */
13
+ export declare function readConfig(): Promise<Config | null>;
14
+ export declare function writeConfig(newValues: Partial<Config>): Promise<void>;
@@ -0,0 +1,41 @@
1
+ import { readFile, writeFile } from "fs/promises";
2
+ import { homedir } from "os";
3
+ import path from "path";
4
+ export function configPath() {
5
+ return path.join(homedir(), ".zeroheight-config.json");
6
+ }
7
+ /**
8
+ * Read the credentials from the user's home directory
9
+ * @returns access token and client id or null
10
+ */
11
+ export async function readConfig() {
12
+ try {
13
+ const rawData = await readFile(configPath(), "utf8");
14
+ const { token, client, repoNames = {} } = JSON.parse(rawData);
15
+ return { token, client, repoNames };
16
+ }
17
+ catch (e) {
18
+ const client = process.env["ZEROHEIGHT_CLIENT_ID"];
19
+ const token = process.env["ZEROHEIGHT_ACCESS_TOKEN"];
20
+ if (client && token) {
21
+ return { token, client, repoNames: {} };
22
+ }
23
+ return null;
24
+ }
25
+ }
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
32
+ const payload = JSON.stringify({
33
+ ...config,
34
+ ...newValues,
35
+ repoNames: {
36
+ ...config.repoNames,
37
+ ...(newValues.repoNames ?? {}),
38
+ },
39
+ });
40
+ return writeFile(configPath(), payload);
41
+ }
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+ import { RawUsageMap } from "../../commands/analyze.js";
3
+ export interface AnalyzeProps {
4
+ onAnalyzeFiles: () => Promise<{
5
+ errors: string[];
6
+ usage: RawUsageMap;
7
+ }>;
8
+ dryRun: boolean;
9
+ repoName?: string;
10
+ }
11
+ export default function Analyze({ onAnalyzeFiles, dryRun, repoName, }: AnalyzeProps): React.JSX.Element;
@@ -0,0 +1,136 @@
1
+ import React from "react";
2
+ import Spinner from "ink-spinner";
3
+ import { Box, Newline, Text, useApp } from "ink";
4
+ import { configPath, writeConfig, readConfig, } from "../../common/config.js";
5
+ import UsageTable from "../usage-table.js";
6
+ import ContinuePrompt from "../ui/continue-prompt.js";
7
+ import Link from "ink-link";
8
+ import NoCredentialsOnboarding from "../auth/no-credentials-onboarding.js";
9
+ import { getZeroheightURL, submitUsageData } from "../../common/api.js";
10
+ import RepoNamePrompt from "../repo-name-prompt.js";
11
+ export default function Analyze({ onAnalyzeFiles, dryRun, repoName, }) {
12
+ const { exit } = useApp();
13
+ const [usageResult, setUsageResult] = React.useState(null);
14
+ const [isSendingData, setIsSendingData] = React.useState(false);
15
+ const [errorList, setErrorList] = React.useState([]);
16
+ const [credentials, setCredentials] = React.useState(null);
17
+ const [resourceURL, setResourceURL] = React.useState(null);
18
+ const [repo, setRepo] = React.useState(repoName);
19
+ const [promptRepo, setPromptRepo] = React.useState(repoName === undefined);
20
+ React.useEffect(() => {
21
+ onAnalyzeFiles()
22
+ .then(({ errors, usage }) => {
23
+ setErrorList((s) => [...s, ...errors]);
24
+ setUsageResult(usage);
25
+ })
26
+ .catch((e) => setErrorList((s) => [...s, e]));
27
+ if (!dryRun) {
28
+ readConfig().then((config) => {
29
+ if (config) {
30
+ setCredentials({ token: config.token, client: config.client });
31
+ }
32
+ });
33
+ }
34
+ }, []);
35
+ async function handleContinue(shouldContinue) {
36
+ if (shouldContinue) {
37
+ if (!dryRun && credentials && usageResult && !repo) {
38
+ setPromptRepo(true);
39
+ setRepo("");
40
+ }
41
+ else if (!dryRun && credentials && usageResult && repo) {
42
+ setIsSendingData(true);
43
+ try {
44
+ await submitUsageData(usageResult, repo, credentials);
45
+ const resourceURL = getZeroheightURL();
46
+ resourceURL.pathname = "/adoption/";
47
+ setResourceURL(resourceURL);
48
+ }
49
+ catch (e) {
50
+ const errorMessage = e.message === "Unauthorized"
51
+ ? "Unauthorized. Please reset your authentication by running: zh-adoption auth"
52
+ : "Failed to send data to zeroheight";
53
+ setErrorList((s) => [...s, errorMessage]);
54
+ }
55
+ finally {
56
+ setIsSendingData(false);
57
+ }
58
+ }
59
+ }
60
+ else {
61
+ exit();
62
+ }
63
+ }
64
+ function handleOnRepoNameSelected(repoName) {
65
+ setRepo(repoName);
66
+ writeConfig({
67
+ repoNames: {
68
+ [process.cwd()]: repoName,
69
+ },
70
+ });
71
+ setPromptRepo(false);
72
+ }
73
+ if (errorList.length > 0) {
74
+ return (React.createElement(Box, { flexDirection: "column" },
75
+ React.createElement(Text, { color: "red" }, "Error:"),
76
+ React.createElement(Box, { flexDirection: "column", marginLeft: 1 }, errorList.map((e) => (React.createElement(Text, { key: e },
77
+ "- ",
78
+ e))))));
79
+ }
80
+ if (!credentials && !dryRun) {
81
+ return (React.createElement(NoCredentialsOnboarding, { configPath: configPath(), onSaveCredentials: async (persist, newClient, newToken) => {
82
+ const newCredentials = { token: newToken, client: newClient };
83
+ setCredentials(newCredentials);
84
+ if (persist) {
85
+ await writeConfig({
86
+ client: newClient,
87
+ token: newToken,
88
+ });
89
+ }
90
+ } }));
91
+ }
92
+ // Usage is being calculated
93
+ if (usageResult === null) {
94
+ return (React.createElement(Text, null,
95
+ React.createElement(Text, { color: "green" },
96
+ React.createElement(Spinner, { type: "dots" })),
97
+ " Scanning files..."));
98
+ }
99
+ // No usage data found
100
+ if (usageResult?.size === 0) {
101
+ return (React.createElement(Text, null, "No files found, try a different directory or change the file extensions you want to include"));
102
+ }
103
+ // Posting usage data to zeroheight
104
+ if (isSendingData) {
105
+ return (React.createElement(Text, null,
106
+ React.createElement(Text, { color: "green" },
107
+ React.createElement(Spinner, { type: "dots" })),
108
+ " Sending usage data to zeroheight..."));
109
+ }
110
+ // Completed, usage data is ready to view on zeroheight
111
+ if (resourceURL) {
112
+ return (React.createElement(React.Fragment, null,
113
+ React.createElement(Newline, null),
114
+ React.createElement(Text, null,
115
+ React.createElement(Text, { color: "green" }, "Successful:"),
116
+ " View your",
117
+ " ",
118
+ React.createElement(Link, { url: resourceURL.toString() }, "usage data on zeroheight"))));
119
+ }
120
+ if (usageResult && !promptRepo) {
121
+ return (React.createElement(Box, { flexDirection: "column" },
122
+ React.createElement(UsageTable, { usage: usageResult }),
123
+ dryRun && (React.createElement(React.Fragment, null,
124
+ React.createElement(Newline, null),
125
+ React.createElement(Text, null,
126
+ "Nothing sent to zeroheight, if you want to track component usage data then remove the ",
127
+ React.createElement(Text, { italic: true }, "--dry-run"),
128
+ " option."),
129
+ React.createElement(Newline, null))),
130
+ !dryRun && React.createElement(ContinuePrompt, { onContinue: handleContinue })));
131
+ }
132
+ if (promptRepo) {
133
+ return (React.createElement(RepoNamePrompt, { credentials: credentials, onRepoNameSelected: handleOnRepoNameSelected }));
134
+ }
135
+ return React.createElement(Text, null, "NOPE");
136
+ }
@@ -0,0 +1,16 @@
1
+ import * as React from "react";
2
+ interface CredentialsAlreadyExistsProps {
3
+ client?: string;
4
+ token?: string;
5
+ configPath: string;
6
+ existingConfig: {
7
+ client: string;
8
+ token: string;
9
+ };
10
+ onOverwriteCredentials: () => Promise<void>;
11
+ }
12
+ /**
13
+ * Show when credentials already exist and prompt user to overwrite if applicable
14
+ */
15
+ export default function CredentialsAlreadyExists({ client, token, configPath, existingConfig, onOverwriteCredentials, }: CredentialsAlreadyExistsProps): React.JSX.Element;
16
+ export {};
@@ -0,0 +1,31 @@
1
+ import * as React from "react";
2
+ import { Box, Text, useApp } from "ink";
3
+ import Link from "ink-link";
4
+ import CredentialsPreview from "./credentials-preview.js";
5
+ import ConfirmInput from "../ui/confirm-input.js";
6
+ /**
7
+ * Show when credentials already exist and prompt user to overwrite if applicable
8
+ */
9
+ export default function CredentialsAlreadyExists({ client, token, configPath, existingConfig, onOverwriteCredentials, }) {
10
+ const { exit } = useApp();
11
+ const [shouldOverwrite, setShouldOverwrite] = React.useState("");
12
+ async function handleSubmitOverwrite(override) {
13
+ if (override) {
14
+ await onOverwriteCredentials();
15
+ }
16
+ exit();
17
+ }
18
+ return (React.createElement(Box, { flexDirection: "column" },
19
+ React.createElement(Text, { bold: true, color: "yellow" }, "Credentials already exist"),
20
+ React.createElement(Text, null,
21
+ "You already have credentials in",
22
+ " ",
23
+ React.createElement(Link, { url: configPath }, configPath)),
24
+ React.createElement(CredentialsPreview, { client: existingConfig.client, token: existingConfig.token }),
25
+ (client || token) && (React.createElement(Box, null,
26
+ React.createElement(Text, null,
27
+ "Would you like to overwrite these credentials?",
28
+ " ",
29
+ React.createElement(Text, { dimColor: true }, "(y/N)")),
30
+ React.createElement(ConfirmInput, { isChecked: false, value: shouldOverwrite, onChange: setShouldOverwrite, onSubmit: handleSubmitOverwrite })))));
31
+ }
@@ -0,0 +1,10 @@
1
+ import * as React from "react";
2
+ interface CredentialsPreviewProps {
3
+ client: string;
4
+ token: string;
5
+ }
6
+ /**
7
+ * Show Client ID and Access Token (redacted/sensitive) for confirmation
8
+ */
9
+ export default function CredentialsPreview({ client, token, }: CredentialsPreviewProps): React.JSX.Element;
10
+ export {};
@@ -0,0 +1,20 @@
1
+ import * as React from "react";
2
+ import { Box, Text } from "ink";
3
+ const REDACT_REVEAL_COUNT = 6;
4
+ /**
5
+ * Show Client ID and Access Token (redacted/sensitive) for confirmation
6
+ */
7
+ export default function CredentialsPreview({ client, token, }) {
8
+ const asterisks = new Array(Math.max(3, token.length - 2 * REDACT_REVEAL_COUNT)).fill("✲");
9
+ return (React.createElement(Box, { borderStyle: "single", margin: 1, padding: 1 },
10
+ React.createElement(Box, { flexDirection: "column" },
11
+ React.createElement(Box, { gap: 7 },
12
+ React.createElement(Text, { bold: true }, "Client ID:"),
13
+ React.createElement(Text, null, client)),
14
+ React.createElement(Box, { gap: 4 },
15
+ React.createElement(Text, { bold: true }, "Access Token:"),
16
+ React.createElement(Text, null,
17
+ token.slice(0, REDACT_REVEAL_COUNT),
18
+ asterisks,
19
+ token.slice(token.length - REDACT_REVEAL_COUNT, token.length))))));
20
+ }
@@ -0,0 +1,7 @@
1
+ import * as React from "react";
2
+ interface NoCredentialsOnboardingProps {
3
+ onSaveCredentials: (storeCredentials: boolean, client: string, token: string) => Promise<void>;
4
+ configPath: string;
5
+ }
6
+ export default function NoCredentialsOnboarding({ onSaveCredentials, configPath, }: NoCredentialsOnboardingProps): React.JSX.Element;
7
+ export {};
@@ -0,0 +1,45 @@
1
+ import * as React from "react";
2
+ import { Box, Newline, Text } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ import Link from "ink-link";
5
+ import CredentialsPreview from "./credentials-preview.js";
6
+ import ConfirmInput from "../ui/confirm-input.js";
7
+ export default function NoCredentialsOnboarding({ onSaveCredentials, configPath, }) {
8
+ const [client, setClient] = React.useState("");
9
+ const [token, setToken] = React.useState("");
10
+ const [shouldWrite, setShouldWrite] = React.useState("");
11
+ const [field, setField] = React.useState("client");
12
+ const handleWriteCredentials = async (answer) => {
13
+ await onSaveCredentials(answer, client.trim(), token.trim());
14
+ };
15
+ return (React.createElement(Box, { flexDirection: "column" },
16
+ React.createElement(Text, null,
17
+ "If you need a new auth token, generate them from the",
18
+ " ",
19
+ React.createElement(Link, { url: "https://zeroheight.com/settings/team/developers" }, "Developer Settings")),
20
+ field === "client" && (React.createElement(Box, null,
21
+ React.createElement(Text, null, "Client ID: "),
22
+ React.createElement(TextInput, { onChange: setClient, value: client, onSubmit: (v) => {
23
+ if (v.trim().length === 0)
24
+ return;
25
+ setField("token");
26
+ } }))),
27
+ field === "token" && (React.createElement(Box, null,
28
+ React.createElement(Text, null, "Access Token:"),
29
+ React.createElement(TextInput, { onChange: setToken, value: token, onSubmit: (v) => {
30
+ if (v.trim().length === 0)
31
+ return;
32
+ setField("write");
33
+ } }))),
34
+ field === "write" && (React.createElement(Box, { flexDirection: "column" },
35
+ React.createElement(CredentialsPreview, { client: client, token: token }),
36
+ React.createElement(Text, null,
37
+ "Would you like to write these credentials to",
38
+ " ",
39
+ React.createElement(Link, { url: configPath }, configPath),
40
+ "?",
41
+ " ",
42
+ React.createElement(Text, { dimColor: true }, "(Y/n)")),
43
+ React.createElement(ConfirmInput, { isChecked: true, onChange: setShouldWrite, onSubmit: handleWriteCredentials, value: shouldWrite }))),
44
+ React.createElement(Newline, null)));
45
+ }
@@ -0,0 +1,5 @@
1
+ import * as React from "react";
2
+ /**
3
+ * Rich help banner with link to help center docs
4
+ */
5
+ export default function HelpInfo(): React.JSX.Element;
@@ -0,0 +1,24 @@
1
+ import * as React from "react";
2
+ import { Box, Newline, Text } from "ink";
3
+ import Link from "ink-link";
4
+ /**
5
+ * Rich help banner with link to help center docs
6
+ */
7
+ export default function HelpInfo() {
8
+ return (React.createElement(React.Fragment, null,
9
+ React.createElement(Box, { borderStyle: "double", padding: 2, margin: 1, flexDirection: "column" },
10
+ React.createElement(Box, { justifyContent: "center" },
11
+ React.createElement(Text, { bold: true }, "Welcome \uD83D\uDC4B")),
12
+ React.createElement(Newline, null),
13
+ React.createElement(Text, null,
14
+ "Get started with the",
15
+ " ",
16
+ React.createElement(Text, { color: "#f63e7c", bold: true }, "zeroheight"),
17
+ " ",
18
+ "measurement CLI and start tracking your component usage."),
19
+ React.createElement(Text, null,
20
+ "For more information on how to set this up, check",
21
+ " ",
22
+ React.createElement(Link, { url: "https://zeroheight.com" }, "here"),
23
+ "."))));
24
+ }
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ import * as React from "react";
2
+ interface InkConfirmInputProps {
3
+ isChecked: boolean;
4
+ onChange: (newValue: string) => void;
5
+ onSubmit: (value: boolean) => void;
6
+ placeholder?: string;
7
+ value: string;
8
+ }
9
+ declare const ConfirmInput: ({ isChecked, onChange, onSubmit, placeholder, value, ...props }: InkConfirmInputProps) => React.JSX.Element;
10
+ export default ConfirmInput;
@@ -0,0 +1,10 @@
1
+ import * as React from "react";
2
+ import TextInput from "ink-text-input";
3
+ import yn from "yn";
4
+ const ConfirmInput = ({ isChecked, onChange, onSubmit, placeholder, value, ...props }) => {
5
+ const handleSubmit = React.useCallback((newValue) => {
6
+ onSubmit(yn(newValue, { default: isChecked }));
7
+ }, [isChecked, onSubmit]);
8
+ return (React.createElement(TextInput, { ...props, placeholder: placeholder, value: value, onChange: onChange, onSubmit: handleSubmit }));
9
+ };
10
+ export default ConfirmInput;
@@ -0,0 +1,6 @@
1
+ import * as React from "react";
2
+ interface ContinuePromptProps {
3
+ onContinue: (answer: boolean) => void;
4
+ }
5
+ export default function ContinuePrompt({ onContinue }: ContinuePromptProps): React.JSX.Element;
6
+ export {};
@@ -0,0 +1,13 @@
1
+ import * as React from "react";
2
+ import { Box, Text } from "ink";
3
+ import ConfirmInput from "./confirm-input.js";
4
+ export default function ContinuePrompt({ onContinue }) {
5
+ const [shouldContinueText, setShouldContinueText] = React.useState("");
6
+ return (React.createElement(Box, null,
7
+ React.createElement(Text, null,
8
+ "Continue? ",
9
+ React.createElement(Text, { dimColor: true }, "(Y/n):")),
10
+ React.createElement(ConfirmInput, { isChecked: true, value: shouldContinueText, onChange: setShouldContinueText, onSubmit: (answer) => {
11
+ onContinue(answer);
12
+ } })));
13
+ }
@@ -0,0 +1,7 @@
1
+ import * as React from "react";
2
+ import { RawUsage } from "../ast/analyze.js";
3
+ interface UsageTableProps {
4
+ usage: Map<string, RawUsage[]>;
5
+ }
6
+ export default function UsageTable({ usage }: UsageTableProps): React.JSX.Element;
7
+ export {};
@@ -0,0 +1,14 @@
1
+ import * as React from "react";
2
+ import { Box, Text } from "ink";
3
+ export default function UsageTable({ usage }) {
4
+ const rows = Array.from(usage.entries());
5
+ return (React.createElement(Box, { flexDirection: "column" }, rows.map(([filepath, fileUsage]) => {
6
+ return (React.createElement(Box, { key: filepath, flexDirection: "column" },
7
+ React.createElement(Text, { color: "green" }, filepath),
8
+ React.createElement(Box, { flexDirection: "column", marginLeft: 2 }, fileUsage.map(({ name, count }) => {
9
+ return (React.createElement(Box, { gap: 2, key: `${name}-${filepath}` },
10
+ React.createElement(Text, { bold: true }, count),
11
+ React.createElement(Text, null, name)));
12
+ }))));
13
+ })));
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeroheight/adoption-cli",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "license": "ISC",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "scripts": {
14
14
  "cleanup": "rm -rf dist",
15
15
  "setup": "npm run build && npm i -g",
16
- "start": "zh-adoption",
16
+ "start": "node dist/cli.js",
17
17
  "start-dev": "export NODE_ENV=dev && zh-adoption",
18
18
  "build": "tsc",
19
19
  "dev": "tsc --watch",
@@ -30,9 +30,9 @@
30
30
  "acorn-walk": "^8.3.2",
31
31
  "chalk": "^5.2.0",
32
32
  "commander": "^12.0.0",
33
+ "glob": "^10.3.15",
33
34
  "ignore": "^5.3.1",
34
35
  "ink": "^4.1.0",
35
- "glob": "^10.3.15",
36
36
  "ink-link": "^3.0.0",
37
37
  "ink-render-string": "^1.0.0",
38
38
  "ink-select-input": "^5.0.0",
@@ -52,7 +52,7 @@
52
52
  "ink-testing-library": "git+ssh://git@github.com/vadimdemedes/ink-testing-library.git#f44b077e9a05a1d615bab41c72906726d34ea085",
53
53
  "memfs": "^4.9.2",
54
54
  "mock-stdin": "^1.0.0",
55
- "prettier": "^2.8.7",
55
+ "prettier": "^2.8.8",
56
56
  "ts-node": "^10.9.1",
57
57
  "typescript": "^5.4.5",
58
58
  "vitest": "^1.5.2"