diff --git a/cli/.public-credentials/readme.md b/cli/.public-credentials/readme.md new file mode 100644 index 00000000..a9907aac --- /dev/null +++ b/cli/.public-credentials/readme.md @@ -0,0 +1 @@ +https://bit.ly/3dxp0xW diff --git a/cli/.runtime-env/.env b/cli/.runtime-env/.env new file mode 100644 index 00000000..995fca4a --- /dev/null +++ b/cli/.runtime-env/.env @@ -0,0 +1 @@ +NODE_ENV=production \ No newline at end of file diff --git a/cli/.runtime-env/.gitignore b/cli/.runtime-env/.gitignore new file mode 100644 index 00000000..1e18f275 --- /dev/null +++ b/cli/.runtime-env/.gitignore @@ -0,0 +1 @@ +!.env \ No newline at end of file diff --git a/cli/.runtime-env/readme.md b/cli/.runtime-env/readme.md new file mode 100644 index 00000000..e51aac86 --- /dev/null +++ b/cli/.runtime-env/readme.md @@ -0,0 +1,18 @@ +# this directory is for explicit .env for settings `NODE_ENV` for ncc build + +Since the ncc does not opt us to set runtime env, so we'll need to load & set the env manually. + +```ts +import dotenv from "dotenv"; +dotenv.load({ + path: "./.runtime-env/.env", +}); +``` + +## Note for contributors + +Is this required?: + +Not essential, but some of our modules use logging conditioning based on `NODE_ENV !== "production"` not `NODE_ENV === "development"`. + +So in most case, this will not change the core engine's logic behind, this is for disable verbose logging at this point. diff --git a/cli/README.md b/cli/README.md index 2476a150..72f5d516 100644 --- a/cli/README.md +++ b/cli/README.md @@ -8,18 +8,23 @@ Visit https://grida.co/cli for more information. npm install -g grida ``` +## Documentation + +Visit https://grida.co/docs/cli + ## Commands -| Commands | | | -| -------------------- | --------------------------------------- | ---------------------------------------------------- | -| `grida code react` | convert input uri (file or url) to code | `designto react https://www.figma.com/files/XXX` | -| `grida code rn` | convert input uri (file or url) to code | `designto rn https://www.figma.com/files/XXX` | -| `grida code vue` | convert input uri (file or url) to code | `grida code vue https://www.figma.com/files/XXX` | -| `grida code svelte` | convert input uri (file or url) to code | `grida code svelte https://www.figma.com/files/XXX` | -| `grida code solid` | convert input uri (file or url) to code | `grida code flutter https://www.figma.com/files/XXX` | -| `grida code flutter` | help | `designto flutter https://www.figma.com/files/XXX` | -| `grida code auth` | signin to design services | `auto` \| `figma` \| `sketch` \| `xd` | -| `grida code init` | configure the preferences | +| Commands | | | +| ----------------------- | --------------------------------------- | ----------------------------------------------------- | +| `grida init` | configure the preferences | | +| `grida login` | signin to design services | `auto` \| `figma` \| `sketch` \| `xd` | +| `grida add [modules..]` | add new modules to existing project | `grida add` | +| `grida code react` | convert input uri (file or url) to code | `grida code react https://www.figma.com/files/XXX` | +| `grida code rn` | convert input uri (file or url) to code | `grida code rn https://www.figma.com/files/XXX` | +| `grida code vue` | convert input uri (file or url) to code | `grida code vue https://www.figma.com/files/XXX` | +| `grida code svelte` | convert input uri (file or url) to code | `grida code svelte https://www.figma.com/files/XXX` | +| `grida code solid-js` | convert input uri (file or url) to code | `grida code solid-js https://www.figma.com/files/XXX` | +| `grida code flutter` | help | `grida code flutter https://www.figma.com/files/XXX` | ## Args diff --git a/cli/auth/api.ts b/cli/auth/api.ts index ae0a775e..4ced99e9 100644 --- a/cli/auth/api.ts +++ b/cli/auth/api.ts @@ -9,8 +9,9 @@ import Axios from "axios"; import { machineIdSync } from "node-machine-id"; import { AuthStore } from "./store"; -const PROXY_AUTH_REQUEST_SECRET = - process.env.GRIDA_FIRST_PARTY_PROXY_AUTH_REQUEST_TOTP_SECRET; +// it is ok to load dynamically since its cli env. +const PROXY_AUTH_REQUEST_SECRET = () => + process.env.PUBLIC_GRIDA_FIRST_PARTY_PROXY_AUTH_REQUEST_TOTP_SECRET; function _termenv(): "vscode" | "terminal" | "unknown" { switch (process.env.TERM_PROGRAM) { @@ -33,7 +34,7 @@ function _make_request(): AuthProxySessionStartRequest { export async function startAuthenticationSession(): Promise { return __auth_proxy.openProxyAuthSession( - PROXY_AUTH_REQUEST_SECRET, + PROXY_AUTH_REQUEST_SECRET(), _make_request() ); } @@ -42,7 +43,7 @@ export async function startAuthenticationWithSession( session: AuthProxySessionStartResult ) { const result = await __auth_proxy.requesetProxyAuthWithSession( - PROXY_AUTH_REQUEST_SECRET, + PROXY_AUTH_REQUEST_SECRET(), session, _make_request() ); @@ -69,7 +70,7 @@ export async function getAccessToken(): Promise { export async function checkAuthSession(session: string): Promise { // TODO: const res = await __auth_proxy.checkProxyAuthResult( - PROXY_AUTH_REQUEST_SECRET, + PROXY_AUTH_REQUEST_SECRET(), session ); @@ -88,7 +89,7 @@ const secure_axios = async () => { }, }); cors.useAxiosCors(axios, { - apikey: process.env.GRIDA_FIRST_PARTY_CORS_API_KEY, + apikey: process.env.PUBLIC_GRIDA_FIRST_PARTY_CORS_API_KEY, }); return axios; }; diff --git a/cli/auth/login.ts b/cli/auth/login.ts index 246fee8f..1b1caf4c 100644 --- a/cli/auth/login.ts +++ b/cli/auth/login.ts @@ -27,6 +27,7 @@ export async function login() { url = authUrl; await open(authUrl); } catch (e) { + throw new Error("Error while starting authentication session"); exit(1); } diff --git a/cli/bin.ts b/cli/bin.ts index 15da436a..493a3679 100644 --- a/cli/bin.ts +++ b/cli/bin.ts @@ -1,7 +1,11 @@ import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { defaultConfigByFramework } from "@grida/builder-config-preset"; -import { init } from "./init"; +import { + init, + prompt_figma_personal_access_token, + prompt_framework_config, +} from "./init"; import { add } from "./add"; import { code } from "./code"; import { Framework } from "@grida/builder-platform-types"; @@ -11,6 +15,8 @@ import fs from "fs"; import { checkForUpdate } from "./update"; import { login, logout } from "./auth"; import { startFlutterDaemonServer } from "./flutter/daemon"; +import { parseFileId } from "@design-sdk/figma-url"; +import chalk from "chalk"; function loadenv(argv) { const { cwd } = argv; @@ -18,7 +24,7 @@ function loadenv(argv) { const dotenvpath = path.join(cwd, ".env"); if (fs.existsSync(dotenvpath)) { dotenv.config({ path: dotenvpath }); - console.log("Loaded .env file"); + console.info(chalk.dim("Loaded .env file")); } } @@ -67,7 +73,7 @@ export default async function cli() { async () => { login(); }, - [loadenv] + [] ) .command( "logout", @@ -76,28 +82,33 @@ export default async function cli() { async () => { logout(); }, - [loadenv] + [] ) .command( - "code ", + "code [framework] ", "generate code from input uri", (argv) => { // return; }, async ({ cwd, framework, uri, out, ...argv }) => { // - const _personal_access_token = argv[ - "figma-personal-access-token" - ] as string; + + const filekey = parseFileId(uri as string); + + // promp if not set + const _personal_access_token: string = + (argv["figma-personal-access-token"] as string) ?? + (await prompt_figma_personal_access_token(filekey)); // make this path absolute if relative path is given. const _outpath_abs: string = path.isAbsolute(out as string) ? (out as string) : path.resolve(cwd, out as string); - const config_framework = defaultConfigByFramework( - framework as Framework - ); + const config_framework = framework + ? defaultConfigByFramework(framework as Framework) + : await prompt_framework_config(cwd, undefined, false); + if (!config_framework) { throw new Error(`Unknown framework: "${framework}"`); } diff --git a/cli/index.ts b/cli/index.ts index 2398071a..4b5acca9 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -1,13 +1,37 @@ #!/usr/bin/env node import cli from "./bin"; +import chalk from "chalk"; export type { GridaConfig } from "./config"; +import dotenv from "dotenv"; +import path from "path"; -process.on("SIGINT", () => { - process.exit(0); // now the "exit" event will fire -}); +// process +// .on("SIGINT", () => { +// process.exit(0); // now the "exit" event will fire +// }) +// .on("uncaughtException", (err) => { +// console.log(chalk.bgRed(err.message ?? err)); +// process.exit(1); +// }) +// .on("unhandledRejection", (err: Error, p) => { +// console.error(chalk.bgRed(err.message ?? err)); +// process.exit(1); +// }); // if main if (require.main === module) { + // load env for accessing grida services + dotenv.config({ + path: path.join(__dirname, ".public-credentials", ".env"), + }); + + /** + * load env for production @see {@link /cli/.runtime-env/readme.md} + */ + dotenv.config({ + path: path.join(__dirname, ".runtime-env", ".env"), + }); + cli(); } diff --git a/cli/init/index.ts b/cli/init/index.ts index 39173d30..4d155fe6 100644 --- a/cli/init/index.ts +++ b/cli/init/index.ts @@ -16,6 +16,8 @@ import { init_base_project_with_template } from "./init-base-project-with-teplat import { create_grida_config_js } from "./init-grida.config.js"; import { init_dotgrida } from "./init-.grida"; import { init_gitignore } from "./init-.gitignore"; +import { prompt_framework_config } from "./init-config-framework"; +import { init_package_json } from "./init-package.json"; export async function init( cwd = process.cwd(), @@ -28,24 +30,36 @@ export async function init( } if (!baseproj) { - const { continue_witout_existing_project } = await prompt<{ - continue_witout_existing_project: boolean; + const { continue_with_new_project_using_template } = await prompt<{ + continue_with_new_project_using_template: boolean; }>({ - name: "continue_witout_existing_project", + name: "continue_with_new_project_using_template", + initial: true, type: "confirm", message: - "No project root is found (package.json or pubspec.yml) with framework configuration. Do you want to continue without creating a project?", + "No project is found (package.json or pubspec.yml) with framework configuration. Do you want to create new project with template?", }); - if (!continue_witout_existing_project) { + if (continue_with_new_project_using_template) { const _ = await init_base_project_with_template(cwd, { create_cwd_if_not_exists, }); if (_ == "exit") { + console.log( + "Cancelled. You can run `grida init` again after creating a project." + ); exit(0); } else { const { created, cwd: newcwd, name } = _; - if (created) + if (created) { + console.log("\n\n\n"); + console.info( + chalk.bgGreen( + "Fresh project created. Resuming `grida init` process." + ) + ); + console.log(`\n`); return init(newcwd, { name, initialized_with_template: true }); + } } return; } @@ -95,6 +109,16 @@ export async function init( init_gitignore(cwd, { template: framework_gitignore_templates[config_framework.framework], }); // init .gitignore first (why? cause we're dealing with user's local directory here. we don't want to mass things up. .gitignore first.) + + if (is_node_project(config_framework.framework)) { + await init_package_json(cwd, { + dependencies: config_framework.packages.map((p) => ({ + name: p, + version: "latest", + })), + }); + } + // creates grida.config.js create_grida_config_js(cwd, { name: name, @@ -122,120 +146,6 @@ const framework_gitignore_templates = { "react-native": "react-native", } as const; -async function prompt_framework_config( - cwd, - baseproj?: BaseProjectInfo | undefined, - initialized_with_template?: boolean -): Promise { - let framework: BaseProjectInfo["framework"] = baseproj?.framework; - if (!initialized_with_template) { - if (framework && framework !== "unknown") { - const _rel_path_to_config_file = path.relative(cwd, baseproj.config_file); - console.log( - `${framework} configuration found in ${_rel_path_to_config_file}` - ); - } else { - let choices: Array = [ - "flutter", - "react", - "react-native", - "vanilla", - "solid-js", - // "vue", - ]; - - if (baseproj?.framework == "unknown") { - choices = choices.filter((f) => - baseproj.allowed_frameworks.includes(f) - ); - } - - const { framework: _framework } = await prompt<{ - framework: FrameworkConfig["framework"]; - }>({ - name: "framework", - type: "select", - message: "Select framework", - // initial: baseproj?.framework, - choices: choices, - }); - framework = _framework; - } - } - - const _selection_ux_guide_msg = "(press space to select, enter to confirm)"; - switch (framework) { - case "react": { - const packages = ["styled-components", "@emotion/styled"]; - // TODO: - const { styles } = await prompt<{ styles: string[] }>({ - name: "styles", - message: "Select style " + _selection_ux_guide_msg, - type: "multiselect", - initial: baseproj?.packages ?? [], - // @ts-ignore - choices: [ - { - name: "css", - value: "css", - }, - ...packages.map((p) => { - return { - name: p, - value: p, - hint: baseproj?.packages?.includes(p) - ? " (found from package.json)" - : undefined, - }; - }), - { - name: "inline-style", - value: "inline-style", - hint: `
`, - }, - { - name: "others", - }, - // "scss", - // "less", - ], - }); - } - } - - switch (framework) { - case "react-native": - case "react": { - return { - framework: framework as "react", - language: Language.tsx, - // TODO: - styling: { - type: "styled-components", - module: "@emotion/styled", - }, - component_declaration_style: { - // TODO: - exporting_style: { - type: "export-named-functional-component", - declaration_syntax_choice: "function", - export_declaration_syntax_choice: "export", - exporting_position: "with-declaration", - }, - }, - }; - } - case "flutter": { - return { framework: "flutter", language: Language.dart }; - } - default: { - return { - framework: framework as FrameworkConfig["framework"], - } as FrameworkConfig; - } - } -} - function create_cwd_if_not_exists(cwd = process.cwd()) { if (!fs.existsSync(cwd)) { fs.mkdirSync(cwd); @@ -283,3 +193,29 @@ function create_grida_fallback_dir( path: dir, }; } + +function is_node_project(framework: FrameworkConfig["framework"]) { + switch (framework) { + case "flutter": + return false; + case "react": + case "react-native": + case "solid-js": + return true; + case "vanilla": + case "preview": + default: + return false; + } +} + +export * from "./init-.env"; +export * from "./init-.gitignore"; +export * from "./init-.grida"; +export * from "./init-base-project-with-teplate"; +export * from "./init-config-designsource"; +export * from "./init-config-designsource-figma"; +export * from "./init-config-framework"; +export * from "./init-grida.config.js"; +export * from "./init-package.json"; +export * from "./init-pubspec.yaml"; diff --git a/cli/init/init-.env.ts b/cli/init/init-.env.ts index 309b1404..69fec84c 100644 --- a/cli/init/init-.env.ts +++ b/cli/init/init-.env.ts @@ -38,7 +38,9 @@ export function addDotEnv( const dotenv_content = fs.readFileSync(dotenv_file, "utf8"); const dotenv_lines = dotenv_content.split("\n"); const keys = dotenv_lines.map((l) => l.split("=")[0].trim()); - const linetoadd = `${key}=${value}`; + // e.g + // FIGMA_PERSONAL_ACCESS_TOKEN="figd_YBXD5BQ6jle_qhG_fr_lxxxxxxxxxxxxxxxx" + const linetoadd = `${key}="${value}"`; if (keys.some((k) => k === key)) { // key already exists if (allowOverwrite) { diff --git a/cli/init/init-base-project-with-teplate.ts b/cli/init/init-base-project-with-teplate.ts index d81e1ba5..17a7dda0 100644 --- a/cli/init/init-base-project-with-teplate.ts +++ b/cli/init/init-base-project-with-teplate.ts @@ -1,6 +1,7 @@ import { spawnSync, SpawnSyncOptions } from "child_process"; import { prompt } from "enquirer"; import path from "path"; +import fs from "fs"; import { binexists } from "../_utils/bin-exists"; export async function init_base_project_with_template( @@ -32,7 +33,6 @@ export async function init_base_project_with_template( const template: string = _["template"]; if (template === "cancel") { - console.log("Please run `grida init` again after creating a project."); return "exit"; } @@ -43,9 +43,14 @@ export async function init_base_project_with_template( type: "input", }); - // TODO: check if binary is installed first. + if (fs.existsSync(path.join(cwd, name))) { + throw new Error( + `Failed to create fresh project. directory with name "${name}" already exists.` + ); + } create_cwd_if_not_exists(cwd); + // TODO: check if binary is installed first. const __spawan_cfg: SpawnSyncOptions = { stdio: "inherit", cwd }; switch (template) { case "flutter": { diff --git a/cli/init/init-config-designsource-figma.ts b/cli/init/init-config-designsource-figma.ts new file mode 100644 index 00000000..b8a736c5 --- /dev/null +++ b/cli/init/init-config-designsource-figma.ts @@ -0,0 +1,79 @@ +import { prompt } from "enquirer"; +import { parseFileId } from "@design-sdk/figma-url"; +import { + Client as FigmaApiClient, + User as FigmaUser, +} from "@design-sdk/figma-remote-api"; +import chalk from "chalk"; + +export async function prompt_figma_filekey() { + const { url } = await prompt<{ url: string }>({ + name: "url", + message: "Please enter your figma file url", + type: "input", + hint: "https://www.figma.com/file/xxxx", + required: true, + validate(value) { + if (!value) { + return "Please enter your figma file url. (copy & paste the link on the browser)"; + } + try { + const filekey = parseFileId(value); + if (!filekey) { + return false; + } + return true; + } catch (e) { + return e.message; + } + }, + }); + + const filekey = parseFileId(url); + return filekey; +} + +export async function prompt_figma_personal_access_token( + filekey: string +): Promise { + const _ = await prompt({ + name: "figma-personal-access-token", + message: + "Please enter your figma personal access token. (🤔 https://bit.ly/figma-personal-access-token)", + type: "password", + // @ts-ignore + async validate(value) { + // it's usually 43 chars long e.g "xxxxxx-xxxxxxxx-xxxx-xxxx-xxxxxxxxxxxx" + if (!value || value.length < 40 || value.trim().includes(" ")) { + return "Please enter your figma personal access token. How to 👉 https://bit.ly/figma-personal-access-token"; + } + + const validationClient = FigmaApiClient({ + personalAccessToken: value, + }); + + let me: FigmaUser; + try { + me = (await validationClient.me()).data; + } catch (e) { + return "Invalid personal access token. Please try again."; + } + + try { + await validationClient.file(filekey, { + depth: 1, + }); + } catch (e) { + return `This token for user ${chalk.blue( + me.handle + )} has no access to file - ${chalk.blue( + `https://www.figma.com/file/${filekey}` + )} Make sure you are editor of the file.`; + } + + return true as boolean; + }, + }); + + return _["figma-personal-access-token"]; +} diff --git a/cli/init/init-config-designsource.ts b/cli/init/init-config-designsource.ts index 572f7a3a..f28bc791 100644 --- a/cli/init/init-config-designsource.ts +++ b/cli/init/init-config-designsource.ts @@ -1,14 +1,9 @@ import { prompt } from "enquirer"; -import { parseFileId } from "@design-sdk/figma-url"; -import type { - DesignSourceConfig, - FrameworkConfig, -} from "@grida/builder-config"; +import type { DesignSourceConfig } from "@grida/builder-config"; import { - Client as FigmaApiClient, - User as FigmaUser, -} from "@design-sdk/figma-remote-api"; -import chalk from "chalk"; + prompt_figma_filekey, + prompt_figma_personal_access_token, +} from "./init-config-designsource-figma"; export async function prompt_designsource_config(): Promise { const { provider } = await prompt<{ @@ -34,78 +29,10 @@ export async function prompt_designsource_config(): Promise }; } default: { - throw new Error(`Sorry, ${provider} is not supported yet.`); + console.log( + `grida for ${provider} is in private beta. If you want to try it out, please submit this form - https://forms.gle/BNtCCVBUSAYaTtSb7` + ); + process.exit(0); } } } - -async function prompt_figma_filekey() { - const { url } = await prompt<{ url: string }>({ - name: "url", - message: "Please enter your figma file url", - type: "input", - hint: "https://www.figma.com/file/xxxx", - required: true, - validate(value) { - if (!value) { - return "Please enter your figma file url. (copy & paste the link on the browser)"; - } - try { - const filekey = parseFileId(value); - if (!filekey) { - return false; - } - return true; - } catch (e) { - return e.message; - } - }, - }); - - const filekey = parseFileId(url); - return filekey; -} - -async function prompt_figma_personal_access_token( - filekey: string -): Promise { - const _ = await prompt({ - name: "figma-personal-access-token", - message: "Please enter your figma personal access token.", - type: "password", - // @ts-ignore - async validate(value) { - // it's usually 43 chars long e.g "xxxxxx-xxxxxxxx-xxxx-xxxx-xxxxxxxxxxxx" - if (!value || value.length < 40 || value.trim().includes(" ")) { - return "Please enter your figma personal access token. How to 👉 https://grida.co/docs/with-figma/guides/how-to-get-personal-access-token"; - } - - const validationClient = FigmaApiClient({ - personalAccessToken: value, - }); - - let me: FigmaUser; - try { - me = (await validationClient.me()).data; - } catch (e) { - return "Invalid personal access token. Please try again."; - } - - try { - await validationClient.file(filekey, { - depth: 1, - }); - } catch (e) { - return `This token for user ${chalk.blue( - me.handle - )} has no access to file - ${chalk.blue( - `https://www.figma.com/file/${filekey}` - )} Make sure you are editor of the file.`; - } - - return true as boolean; - }, - }); - - return _["figma-personal-access-token"]; -} diff --git a/cli/init/init-config-framework.ts b/cli/init/init-config-framework.ts new file mode 100644 index 00000000..bace80e4 --- /dev/null +++ b/cli/init/init-config-framework.ts @@ -0,0 +1,190 @@ +import path from "path"; +import { BaseProjectInfo } from "../project"; +import { prompt } from "enquirer"; +import type { FrameworkConfig } from "@grida/builder-config"; +import { Language } from "@grida/builder-platform-types"; +import { ReactStylingStrategy } from "@grida/builder-config/framework-react"; + +type FrameworkConfigResult = FrameworkConfig & { + packages: string[]; +}; + +export async function prompt_framework_config( + cwd, + baseproj?: BaseProjectInfo | undefined, + initialized_with_template?: boolean +): Promise { + let framework: BaseProjectInfo["framework"] = baseproj?.framework; + if (!initialized_with_template) { + if (framework && framework !== "unknown") { + const _rel_path_to_config_file = path.relative(cwd, baseproj.config_file); + console.log( + `${framework} configuration found in ${_rel_path_to_config_file}` + ); + } else { + let choices: Array = [ + "flutter", + "react", + "react-native", + "vanilla", + "solid-js", + // "vue", + ]; + + if (baseproj?.framework == "unknown") { + choices = choices.filter((f) => + baseproj.allowed_frameworks.includes(f) + ); + } + + const { framework: _framework } = await prompt<{ + framework: FrameworkConfig["framework"]; + }>({ + name: "framework", + type: "select", + message: "Select framework", + // initial: baseproj?.framework, + choices: choices, + }); + framework = _framework; + } + } + + const _selection_ux_guide_msg = "(press space to select, enter to confirm)"; + switch (framework) { + case "react": { + const packages = ["styled-components", "@emotion/styled"]; + // TODO: + const { styles } = await prompt<{ styles: string[] }>({ + name: "styles", + message: "Select style " + _selection_ux_guide_msg, + type: "multiselect", + initial: baseproj?.packages ?? [], + // @ts-ignore + choices: [ + { + name: "css", + message: "css", + }, + ...packages.map((p) => { + return { + message: p, + name: p, + hint: baseproj?.packages?.includes(p) + ? " (found from package.json)" + : undefined, + }; + }), + { + name: "inline-style", + message: "inline-style", + hint: `
`, + }, + { + name: "others", + }, + // "scss", + // "less", + ], + }); + + // TODO: add multi styling config sypport + let default_style: ReactStylingStrategy = { + type: "styled-components", + module: "@emotion/styled", + }; + let required_packages = ["@emotion/styled", "@emotion/react"]; + if (styles.includes("@emotion/styled")) { + default_style = { + type: "styled-components", + module: "@emotion/styled", + }; + required_packages = ["@emotion/styled", "@emotion/react"]; + } else if (styles.includes("styled-components")) { + default_style = { + type: "styled-components", + module: "styled-components", + }; + required_packages = ["styled-components"]; + } else if (styles.includes("inline-css")) { + default_style = { + type: "inline-css", + }; + required_packages = []; + } + + return { + framework: framework as "react", + language: Language.tsx, + // TODO: + styling: default_style, + component_declaration_style: { + // TODO: + exporting_style: { + type: "export-named-functional-component", + declaration_syntax_choice: "function", + export_declaration_syntax_choice: "export", + exporting_position: "with-declaration", + }, + }, + packages: required_packages, + }; + } + } + + switch (framework) { + case "react-native": { + return { + framework: framework, + language: Language.tsx, + // TODO: + styling: { + type: "styled-components", + module: "@emotion/native", + }, + component_declaration_style: { + // TODO: + exporting_style: { + type: "export-named-functional-component", + declaration_syntax_choice: "function", + export_declaration_syntax_choice: "export", + exporting_position: "with-declaration", + }, + }, + shadow: { + type: "react-native", + module: "react-native", + }, + gradient_text: { + linear: { + module: "react-native-text-gradient", + }, + radial: ["fallback-to-svg"], + }, + gradient: { + linear: { + module: "react-native-linear-gradient", + }, + radial: { + module: "react-native-radial-gradient", + }, + }, + svg: { + module: "react-native-svg", + prefer_mode: "svg-with-path", + }, + packages: ["@emotion/styled", "@emotion/react"], + }; + break; + } + case "flutter": { + return { framework: "flutter", language: Language.dart, packages: [] }; + } + default: { + return { + framework: framework as FrameworkConfig["framework"], + package: [], + } as any as FrameworkConfigResult; + } + } +} diff --git a/cli/init/init-package.json.ts b/cli/init/init-package.json.ts index e69de29b..baf0a601 100644 --- a/cli/init/init-package.json.ts +++ b/cli/init/init-package.json.ts @@ -0,0 +1,33 @@ +import path from "path"; +import { add_dependencies, Dependency } from "../npm"; +export async function init_package_json( + cwd = process.cwd(), + { + dependencies, + }: { + dependencies?: Dependency[]; + } +) { + const packagejson = path.join(cwd, "package.json"); + + const { manifest, installed, updated } = await add_dependencies(packagejson, { + dependencies: dependencies ?? [], + devDependencies: [ + { + name: "grida", + version: "latest", + }, + ], + type: "write-only", + }); + + if (updated.dependencies?.length > 0) { + console.log(` + Added dependencies [${updated.dependencies + .map((d) => `${d.name}@${d.version}`) + .join( + ", " + )}] to package.json. Run \`npm install\` or \`yarn add\` to resolve. + `); + } +} diff --git a/cli/init/init-pubspec.yaml.ts b/cli/init/init-pubspec.yaml.ts index e69de29b..60449622 100644 --- a/cli/init/init-pubspec.yaml.ts +++ b/cli/init/init-pubspec.yaml.ts @@ -0,0 +1 @@ +export function init_pubspec_yaml() {} diff --git a/cli/npm/__test__/insert-dependency.test.ts b/cli/npm/__test__/insert-dependency.test.ts new file mode 100644 index 00000000..45dc4467 --- /dev/null +++ b/cli/npm/__test__/insert-dependency.test.ts @@ -0,0 +1,108 @@ +import path from "path"; +import { add_dependencies } from "../insert"; +import fs from "fs"; +// max 30s +jest.setTimeout(30000); + +test("add dependency to empty package.json (write only)", async () => { + // copy (overwrite) file from template for testing + const template = path.join(__dirname, "./package.empty.json"); + const target = path.join(__dirname, "./package.test.empty.json"); + fs.copyFileSync(template, target, fs.constants.COPYFILE_FICLONE); + + const { manifest: _manifest } = await add_dependencies(target, { + dependencies: [ + { name: "@emotion/react", version: "latest" }, + { name: "@emotion/styled", version: "latest" }, + ], + devDependencies: [{ name: "grida", version: "latest" }], + type: "write-only", + }); + const manifest = _manifest(); + expect( + "@emotion/react" in manifest.dependencies && + "@emotion/styled" in manifest.dependencies && + "grida" in manifest.devDependencies + ).toBe(true); + + // remove file after testing + fs.unlinkSync(target); +}); + +test("add dependency to empty package.json (with install)", async () => { + // copy file from template for testing + const template = path.join(__dirname, "./package.empty.json"); + const target = path.join( + __dirname, + "sub-dir-for-npm-install", + "package.json" + ); + fs.copyFileSync(template, target, fs.constants.COPYFILE_FICLONE); + + const { manifest: _manifest, installed } = await add_dependencies(target, { + dependencies: [ + { name: "@emotion/react", version: "latest" }, + { name: "@emotion/styled", version: "latest" }, + ], + devDependencies: [{ name: "grida", version: "latest" }], + type: "write-and-install", + npmClient: "npm", + }); + + const manifest = _manifest(); + expect( + "@emotion/react" in manifest.dependencies && + "@emotion/styled" in manifest.dependencies && + "grida" in manifest.devDependencies + ).toBe(true); + expect(installed).toBe(true); + + // remove file after testing + fs.unlinkSync(target); +}); + +test("add dependency to fullfilled package.json (write only)", async () => { + const { updated, installed } = await add_dependencies( + path.join(__dirname, "./package.test.fulfilled.json"), + { + dependencies: [ + { name: "@emotion/react", version: "latest" }, + { name: "@emotion/styled", version: "latest" }, + ], + devDependencies: [{ name: "grida", version: "latest" }], + type: "write-only", + } + ); + + expect(updated.dependencies.length).toBe(0); + expect(updated.devDependencies.length).toBe(0); + expect(installed).toBe(false); +}); + +// test("add dependency to fullfilled package.json (with install)", async () => { +// // copy file from template for testing +// const template = path.join(__dirname, "./package.test.fulfilled.json"); +// const target = path.join( +// __dirname, +// "sub-dir-for-npm-install", +// "package.json" +// ); +// fs.copyFileSync(template, target, fs.constants.COPYFILE_FICLONE); + +// const { updated, installed } = await add_dependencies(target, { +// dependencies: [ +// { name: "@emotion/react", version: "latest" }, +// { name: "@emotion/styled", version: "latest" }, +// ], +// devDependencies: [{ name: "grida", version: "latest" }], +// type: "with-npm-client", +// npmClient: "npm", +// }); + +// expect(updated.dependencies.length).toBe(0); +// expect(updated.devDependencies.length).toBe(0); +// expect(installed).toBe(false); + +// // remove file after testing +// fs.unlinkSync(target); +// }); diff --git a/cli/npm/__test__/package.empty.json b/cli/npm/__test__/package.empty.json new file mode 100644 index 00000000..b7e0179b --- /dev/null +++ b/cli/npm/__test__/package.empty.json @@ -0,0 +1,4 @@ +{ + "name": "empty-package", + "description": "this file will be copied to package.test.empty.json for testing" +} \ No newline at end of file diff --git a/cli/npm/__test__/package.test.fulfilled.json b/cli/npm/__test__/package.test.fulfilled.json new file mode 100644 index 00000000..f643326d --- /dev/null +++ b/cli/npm/__test__/package.test.fulfilled.json @@ -0,0 +1,10 @@ +{ + "name": "package-with-deps", + "dependencies": { + "@emotion/react": "^11.10.0", + "@emotion/styled": "^11.10.0" + }, + "devDependencies": { + "grida": "^0.0.15" + } +} \ No newline at end of file diff --git a/cli/npm/__test__/sub-dir-for-npm-install/.gitignore b/cli/npm/__test__/sub-dir-for-npm-install/.gitignore new file mode 100644 index 00000000..515bcd4f --- /dev/null +++ b/cli/npm/__test__/sub-dir-for-npm-install/.gitignore @@ -0,0 +1,2 @@ +package.json +package-lock.json \ No newline at end of file diff --git a/cli/npm/index.ts b/cli/npm/index.ts index 72095083..7245064b 100644 --- a/cli/npm/index.ts +++ b/cli/npm/index.ts @@ -1 +1,3 @@ export * from "./locate"; +export * from "./insert"; +export * from "./type"; diff --git a/cli/npm/insert.ts b/cli/npm/insert.ts index 6c0d6ef0..07edf573 100644 --- a/cli/npm/insert.ts +++ b/cli/npm/insert.ts @@ -1,71 +1,166 @@ -import { spawn } from "child_process"; -import fs from "fs"; +import { PackageManifest } from "@web-builder/nodejs"; +import { npmInsatll } from "./install-npm"; import path from "path"; - -type Dependency = { - name: string; - version?: string | "latest" | "*"; -}; +import fs from "fs"; +import fetch from "node-fetch"; +import assert from "assert"; +import { Dependency } from "./type"; type InstallOption = | { - npmClient: "npm" | "yarn" | "pnpm"; - type: "install"; + type: "write-only"; } | { - type: "update-package.json"; - installAfter: false; + type: "write-and-install"; + npmClient: "npm"; } | { - type: "update-package.json"; - installAfter: true; - npmClient: "npm" | "yarn" | "pnpm"; + type: "with-npm-client"; + npmClient: "npm"; // TODO: support yarn and pnpm }; +interface AddDependencyResult { + error?: Error; + installed: boolean; + before: PackageManifest; + /** + * if the dependency is added by npm i, then the package.json file requires some time to be written. on caller, you'll have to wait and resolve the manifest file after when this is complete. + * This is not provided by this function. + */ + manifest: () => PackageManifest; + updated: { + dependencies: Dependency[]; + devDependencies: Dependency[]; + }; +} + /** * add requested dependencies to package.json * - * - add with install - do not modify package.json, run install command instead - * - don't add with install - + * - type: write-only - only write the dependencies to package.json + * - type: write-and-install - write the dependencies to package.json and install the dependencies with npm client afterwards + * - type: with-npm-client - install the dependencies with npm client and return the manifest file + * - WARNING: this does not return the proper manifest. the installation requires extra time to be fully write to package.json.. + * */ -export function add_dependencies( - cwd = process.cwd(), +export async function add_dependencies( + packagejson: string, { - dependencies, - devDependencies, + dependencies = [], + devDependencies = [], ...installconfig }: { - dependencies: Dependency[]; - devDependencies: Dependency[]; + dependencies?: Dependency[]; + devDependencies?: Dependency[]; } & InstallOption -) { - const packagejson = path.resolve(cwd, "package.json"); - if (!fs.existsSync(packagejson)) { - throw new Error(`package.json not found in ${cwd}`); +): Promise { + if ("npmClient" in installconfig) { + assert( + path.parse(packagejson).base === "package.json", + "installation requires input package.json file path to be exact package.json file" + ); } + + let installed = false; + const cwd = path.dirname(packagejson); const manifest = require(packagejson); - const deps = new Set(Object.keys(manifest.dependencies || {})); - const devdeps = new Set(Object.keys(manifest.devDependencies || {})); + const prev_manifest = { ...manifest }; + const prev_deps = new Set(Object.keys(manifest.dependencies || {})); + const prev_devdeps = new Set(Object.keys(manifest.devDependencies || {})); + + // targets not in prev_deps or prev_devdeps + const target_deps = dependencies.filter( + (dep) => !prev_deps.has(dep.name) && !prev_devdeps.has(dep.name) + ); + const target_devdeps = devDependencies.filter( + (dep) => !prev_deps.has(dep.name) && !prev_devdeps.has(dep.name) + ); + + async function makeDepsMap(deps: Dependency[]) { + return await deps.reduce( + async (acc, dep) => ({ + ...(await acc), + [dep.name]: "^" + (await version(dep)), + }), + {} + ); + } + + const write_file = async () => { + // write target deps & devdeps to package.json + // sort with alphabetical order + + // add deps + const adddeps = await makeDepsMap(target_deps); + + manifest.dependencies = { + ...manifest.dependencies, + ...adddeps, + }; + manifest.dependencies = sort_az(manifest.dependencies); + + // add devdeps + manifest.devDependencies = { + ...manifest.devDependencies, + ...(await makeDepsMap(target_devdeps)), + }; + manifest.devDependencies = sort_az(manifest.devDependencies); + + // write package.json + await fs.promises.writeFile(packagejson, JSON.stringify(manifest, null, 4)); + }; switch (installconfig.type) { - case "install": { + case "write-only": { + await write_file(); + break; + } + case "write-and-install": { + await write_file(); + await npmInsatll(cwd, [], {}); + installed = true; + break; + } + case "with-npm-client": { + // let the spawned process handle the write & install const { npmClient } = installconfig; - const install = npmClient === "npm" ? "install" : "add"; - // deps - if (dependencies.length > 0) { - } - spawn(npmClient, [install, ...dependencies.map((d) => d.name)], { cwd }); - - // devdeps - if (devDependencies.length > 0) { - spawn( - npmClient, - [install, dev_flag[npmClient], ...devDependencies.map((d) => d.name)], - { cwd } - ); + switch (npmClient) { + case "npm": { + if (target_deps.length > 0) { + await npmInsatll(cwd, target_deps, { + save: true, + }); + installed = true; + } + + // devdeps + if (target_devdeps.length > 0) { + await npmInsatll(cwd, target_devdeps, { + save: true, + saveDev: true, + }); + installed = true; + } + } } + // const install = npmClient === "npm" ? "install" : "add"; + // deps + + // check if package.json is updated by the spawned process + + break; } } + + return { + installed: installed, + before: prev_manifest, + manifest: () => require(packagejson), + updated: { + dependencies: [...target_deps], + devDependencies: [...target_devdeps], + }, + }; } const dev_flag = { @@ -73,3 +168,56 @@ const dev_flag = { yarn: "--dev", pnpm: "--D", } as const; + +/** + * get latest version from npm + */ +async function latest(pkg: string): Promise { + return ( + await (await fetch(`https://registry.npmjs.org/${pkg}/latest`)).json() + ).version; +} + +async function version(d: Dependency): Promise { + if (d.version !== "latest") { + return d.version; + } else if (d.version === "latest") { + return await latest(d.name); + } else { + return await latest(d.name); + } +} + +function sort_az(obj: object) { + return Object.keys(obj) + .sort() + .reduce((acc, key) => ({ ...acc, [key]: obj[key] }), {}); +} + +/** + * the yarn add or npm install process needs extra time after the child process is complete for the package.json file is to be updated + * this function checks if the process is fully complete. + * @param snapshot + * @param packagejson + * @param timeout + * @returns + */ +function is_install_complete( + snapshot: PackageManifest, + packagejson: string, + timeout = 5000 +): Promise { + return new Promise((resolve) => { + const interval = setInterval(() => { + const manifest = require(packagejson); + // if there is change in file, it means installation is complete + if (JSON.stringify(snapshot) !== JSON.stringify(manifest)) { + clearInterval(interval); + resolve(true); + } + }, 100); + setTimeout(() => { + resolve(false); + }, timeout); + }); +} diff --git a/cli/npm/install-npm.ts b/cli/npm/install-npm.ts new file mode 100644 index 00000000..247eb1c8 --- /dev/null +++ b/cli/npm/install-npm.ts @@ -0,0 +1,52 @@ +/// +/// oriented from - https://github.com/yoshuawuyts/npm-install-package (MIT LICENSE) +/// updated by universe (MIT) +/// + +import { Dependency } from "./type"; +import util from "util"; + +const exec = util.promisify(require("child_process").exec); + +export async function npmInsatll( + cwd = process.cwd(), + deps?: Dependency | Dependency[], + opts?: { + save?: boolean; + saveDev?: boolean; + global?: boolean; + cache?: boolean; + verbose?: boolean; + } +) { + deps = deps ? (Array.isArray(deps) ? deps : [deps]) : []; + opts = opts ?? {}; + + var args = []; + if (opts.save) args.push("-S"); + if (opts.saveDev) args.push("-D"); + if (opts.global) args.push("-g"); + if (opts.cache) args.push("--cache-min Infinity"); + + if (opts.verbose) { + deps.forEach(function (dep) { + process.stdout.write("pkg: " + dep.name + "\n"); + }); + } + + var cliArgs = ["npm i"] + .concat( + args, + deps.map((d) => { + if (d.version) { + return d.name + "@" + d.version; + } else { + return d.name; + } + }) + ) + .join(" "); + await exec(cliArgs, { + cwd, + }); +} diff --git a/cli/npm/type.ts b/cli/npm/type.ts new file mode 100644 index 00000000..c42ccc4a --- /dev/null +++ b/cli/npm/type.ts @@ -0,0 +1,4 @@ +export type Dependency = { + name: string; + version?: string | "latest" | "*"; +}; diff --git a/cli/package.json b/cli/package.json index ac505060..da1bbf85 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,9 +1,23 @@ { "name": "grida", - "version": "0.0.15", + "version": "0.0.22", "private": false, "license": "Apache-2.0", "description": "grida CLI", + "keywords": [ + "grida", + "cli", + "figma", + "design ci", + "figma cli", + "design tokens", + "design system", + "export", + "icons", + "typography", + "components", + "variants" + ], "homepage": "https://grida.co/cli", "repository": "https://github.com/gridaco/code", "dependencies": { @@ -26,9 +40,11 @@ "scripts": { "clean": "rimraf dist", "dev": "ts-node index.ts", + "grida": "yarn dev", "dev:watch": "ts-node-dev index.ts --watch", "test": "jest", - "build": "ncc build index.ts -o dist -e keytar -e glob -e dotenv", + "copy-env": "node scripts/copy-env.js", + "build": "ncc build index.ts -o dist -e keytar -e glob -e dotenv && yarn copy-env", "prepack": "yarn test && yarn clean && yarn build" }, "devDependencies": { @@ -38,6 +54,7 @@ "@types/which": "^2.0.1", "@types/yargs": "^17.0.3", "@vercel/ncc": "^0.34.0", + "fs-extra": "^10.1.0", "jest": "^28.1.3", "ts-jest": "^28.0.7", "ts-node": "^10.9.1", @@ -48,9 +65,9 @@ "grida": "./dist/index.js" }, "files": [ - "dist", "README.md", - "LICENSE" + "LICENSE", + "dist" ], "publishConfig": { "access": "public" diff --git a/cli/scripts/copy-env.js b/cli/scripts/copy-env.js new file mode 100644 index 00000000..c67fbcb3 --- /dev/null +++ b/cli/scripts/copy-env.js @@ -0,0 +1,30 @@ +// copy local .public-credentials under /dist after bundling +// see package.json#scripts#copy-env + +const fs = require("fs-extra"); +const path = require("path"); + +const dist = path.resolve(__dirname, "../dist"); + +function sync_target(target) { + const to = path.resolve(__dirname, `../${target}`); + const tt = path.resolve(dist, target); + // make (e.g. .public-credentials) dir under dist + fs.mkdirSync(path.resolve(dist, target)); + // copy entire (e.g. public-credentials) folder to dist + fs.copySync(to, tt); + // ensure .env exists in dist + fs.ensureFileSync(path.resolve(tt, ".env")); +} +const _public_credentials = ".public-credentials"; +const _runtime_env = ".runtime-env"; + +try { + sync_target(_public_credentials); + sync_target(_runtime_env); +} catch (e) { + console.error( + "Oops. you cannot run copy-env unless you are maintainer of this project." + ); + throw e; +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 8cc3a0ee..3f73fe4b 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -8,5 +8,5 @@ "esModuleInterop": true, "moduleResolution": "node" }, - "exclude": ["dist", "node_modules", "**/*.spec.ts", "__test__"] + "exclude": ["dist", "node_modules", "**/*.spec.ts", "__test__", "scripts"] } diff --git a/cli/yarn.lock b/cli/yarn.lock index 47fef2b6..98737371 100644 --- a/cli/yarn.lock +++ b/cli/yarn.lock @@ -1374,6 +1374,15 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== +fs-extra@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" + integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -1449,7 +1458,7 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -graceful-fs@^4.2.9: +graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.9: version "4.2.10" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== @@ -2028,6 +2037,15 @@ json5@^2.2.1: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + keytar@^7.9.0: version "7.9.0" resolved "https://registry.yarnpkg.com/keytar/-/keytar-7.9.0.tgz#4c6225708f51b50cbf77c5aae81721964c2918cb" @@ -2805,6 +2823,11 @@ typescript@^4.7.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + update-browserslist-db@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.5.tgz#be06a5eedd62f107b7c19eb5bcefb194411abf38"