From 4613abcf1fd0be604713d1eb6a68696effd3ffaa Mon Sep 17 00:00:00 2001 From: Aline Tavares Date: Wed, 7 Feb 2024 10:36:34 -0500 Subject: [PATCH 1/4] PLAT-13612: Adjust how CLI is tracked in Amplitude (#1092) * PLAT-13612: wip; amplitude manager; no refactor of code * remove console.log; promises to prepare for async calls; init within event * update; add missing event; email prioritized in check --- index.js | 32 ++++++++++----- scripts/amplitude/constants.js | 5 +++ scripts/amplitude/scripts.js | 28 +++----------- scripts/amplitude/wrapper.js | 71 ++++++++++++++++++++++++++++++++++ scripts/utils/user.js | 40 +++++++++++++++++++ 5 files changed, 144 insertions(+), 32 deletions(-) create mode 100644 scripts/amplitude/wrapper.js create mode 100644 scripts/utils/user.js diff --git a/index.js b/index.js index a9da5ad40..77492efdf 100755 --- a/index.js +++ b/index.js @@ -36,7 +36,7 @@ import { sendFeedback } from "./scripts/feedback.js"; import { logout } from "./scripts/logout.js"; import { modulesArchive, modulesGet, modulesList } from "./scripts/modules.js"; import { publish } from "./scripts/publish.js"; -import { sendAmplitudeEvent } from "./scripts/amplitude/scripts.js"; +import { Amplitude } from "./scripts/amplitude/wrapper.js"; const pkg = JSON.parse( fs.readFileSync(new URL("package.json", import.meta.url), "utf8") @@ -73,14 +73,6 @@ function dispatcher() { invalid(`command doesn't exist: ${command}`); } - // define the properties to track - const eventProperties = { - full_command: process.argv.slice(2).join(" "), // all of the commands in the user input - action: command // Just the first command - }; - - sendAmplitudeEvent(eventProperties); - return commands[command](); } @@ -142,6 +134,7 @@ const commands = { "--type": String, "--target": String }); + if (!args["--name"]) { invalid("missing required argument: --name"); } @@ -153,6 +146,12 @@ const commands = { `invalid module name provided: '${args["--name"]}'. Use only alphanumeric characters, dashes and underscores.` ); } + + Amplitude.sendEvent({ + name: "Create Module", + properties: { Name: args["--name"] } + }); + createModule(args["--name"], args["--type"], args["--target"], gitRoot()); }, commit: () => { @@ -206,6 +205,7 @@ demo`; const args = arg({ "--version": String }); + Amplitude.sendEvent({ name: "Upgrade Scaffold" }); upgradeScaffold(args["--version"]); }, login: () => { @@ -275,6 +275,7 @@ demo`; switch (action) { case "list": + Amplitude.sendEvent({ name: "List Modules" }); modulesList({ search: args["--search"], status: args["--status"], @@ -291,6 +292,11 @@ demo`; ); } + Amplitude.sendEvent({ + name: "View Module Details", + properties: { "Module Id": id } + }); + modulesGet(id); break; @@ -302,6 +308,11 @@ demo`; ); } + Amplitude.sendEvent({ + name: args["--unarchive"] ? "Unarchive Module" : "Archive Module", + properties: { "Module Id": id } + }); + modulesArchive(id, !!args["--unarchive"]); break; @@ -324,6 +335,9 @@ demo`; } }, publish: () => { + Amplitude.sendEvent({ + name: "Publish Modules" + }); publish(); }, diff --git a/scripts/amplitude/constants.js b/scripts/amplitude/constants.js index 5f62367f6..141a6d4bf 100644 --- a/scripts/amplitude/constants.js +++ b/scripts/amplitude/constants.js @@ -1,2 +1,7 @@ export const PRODUCTION_AMPLITUDE_KEY = "b48a6aaef8d0ac8df1f2a78196473f06"; export const DEVELOPMENT_AMPLITUDE_KEY = "8fa9ae919aaf5bbfb85f8275b734b11c"; + +export const CUSTOMER_TYPE = { + SMB: "SMB", + ENT: "ENT" +}; diff --git a/scripts/amplitude/scripts.js b/scripts/amplitude/scripts.js index 118ce92fe..2a6fbfaa6 100644 --- a/scripts/amplitude/scripts.js +++ b/scripts/amplitude/scripts.js @@ -1,8 +1,7 @@ import inquirer from "inquirer"; import { section } from "../../utils.js"; import { configFile } from "../utils/configFile.js"; -import { AMPLITUDE_API_KEY, HAS_ASKED_OPT_IN_NAME, OPT_IN_NAME } from "./config.js"; -import { init, track } from "@amplitude/analytics-node"; +import { HAS_ASKED_OPT_IN_NAME, OPT_IN_NAME } from "./config.js"; export const askOptIn = async () => { const { optInStatus } = await inquirer.prompt({ @@ -11,7 +10,9 @@ export const askOptIn = async () => { type: "input" }); - if (optInStatus.trim().toLowerCase() !== "y") { + const optedIn = optInStatus.trim().toLowerCase() === "y"; + + if (!optedIn) { section("Thanks for your response. We will not send diagnostics & usage."); configFile.set(OPT_IN_NAME, false); } else { @@ -20,24 +21,5 @@ export const askOptIn = async () => { } configFile.set(HAS_ASKED_OPT_IN_NAME, true); configFile.save(); -}; - -export const sendAmplitudeEvent = (eventProperties) => { - const username = configFile.get("email"); - const isOptedIn = configFile.get(OPT_IN_NAME) || false; - try { - // track only if email is available and user is opted In - if (username && isOptedIn) { - init(AMPLITUDE_API_KEY); - - // track the event - track("Crowdbotics CLI", eventProperties, { - user_id: username - }); - } - } catch (error) { - track("Crowdbotics CLI", { ...eventProperties, amplitudeError: error }, { - user_id: username - }); - } + return optedIn; }; diff --git a/scripts/amplitude/wrapper.js b/scripts/amplitude/wrapper.js new file mode 100644 index 000000000..829d9e738 --- /dev/null +++ b/scripts/amplitude/wrapper.js @@ -0,0 +1,71 @@ +import { configFile } from "../utils/configFile.js"; +import { AMPLITUDE_API_KEY, OPT_IN_NAME } from "./config.js"; +import { init, track, Identify, identify } from "@amplitude/analytics-node"; +import { currentUser } from "../utils/user.js"; + +class AmplitudeWrapper { + get userType() { + // TODO: Implement once we have the data available in the UserSerializer + return undefined; + } + + get optedIn() { + return configFile.get(OPT_IN_NAME); + } + + get appProps() { + return {}; + } + + get userProps() { + const org = currentUser.get("organization"); + return { + "User Type": this.userType, + "Org ID": org?.id, + Source: "CLI" + }; + } + + async init({ token = AMPLITUDE_API_KEY, options = {} } = {}) { + if (!this.optedIn || !token) return; + + try { + await init(token, { ...options, includeUtm: true }).promise; + } catch { + // Ignore errors during initialization + } + } + + async loadAndIdentify(user) { + await currentUser.setUser(user); + if (!currentUser.get("email")) return; + + const identifyEvent = new Identify(); + identifyEvent.set("Django Id", currentUser.get("id")); + identify(identifyEvent, { user_id: currentUser.get("email") }); + } + + async sendEvent({ name, properties = {}, user }) { + if (!this.optedIn) return; + + try { + await this.init(); + await this.loadAndIdentify(user); + + const userEmail = currentUser.get("email"); + if (userEmail) { + const updatedProps = { + ...properties, + ...this.appProps, + ...this.userProps + }; + + await track(name, updatedProps, { user_id: userEmail }).promise; + } + } catch (error) { + console.warn("Error handling analytics - skipping"); + } + } +} + +export const Amplitude = new AmplitudeWrapper(); diff --git a/scripts/utils/user.js b/scripts/utils/user.js new file mode 100644 index 000000000..11c6adff1 --- /dev/null +++ b/scripts/utils/user.js @@ -0,0 +1,40 @@ +import { apiClient } from "./apiClient.js"; + +/** + * A more generic approach to cache the current user information for a session. + */ +class User { + constructor() { + this._user = {}; + } + + get(property) { + return this._user[property]; + } + + async setUser(user) { + if (user) { + this._user = user; + } else { + this._user = await this.load(); + } + } + + async load() { + try { + const response = await apiClient.get({ + path: "/v2/user/" + }); + if (!response.ok) return {}; + + const userBody = await response.json(); + + this.user = userBody; + } catch { + this.user = {}; + } + return this.user; + } +} + +export const currentUser = new User(); From 57a5ddf97dbe957aac3924c06d926506cfb3288d Mon Sep 17 00:00:00 2001 From: "Robert So (robester0403)" <85914248+robester0403@users.noreply.github.com> Date: Wed, 7 Feb 2024 12:24:45 -0500 Subject: [PATCH 2/4] =?UTF-8?q?PLAT-13601=20Added=20inner=20folder=20and?= =?UTF-8?q?=20change=20script=20to=20install=20into=20that=20=E2=80=A6=20(?= =?UTF-8?q?#1088)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PLAT-13601 Added inner folder and change script to install into that folder * PLAT-13601 Instead of using template just do a simple file read and replace to get proper name --- scripts/create.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/create.js b/scripts/create.js index 77d0683a7..8fb5742d1 100644 --- a/scripts/create.js +++ b/scripts/create.js @@ -33,15 +33,21 @@ function generateDjangoFiles(base, name, relative = "/") { const sanitizedName = name.replaceAll("-", "_"); const djangoName = `django_${sanitizedName}`; const basePath = path.join(base, relative, djangoName); + const innerAppPath = path.join(basePath, sanitizedName); - fs.mkdirSync(basePath, { recursive: true }); - execSync(`cd ${basePath}`, execOptions); + fs.mkdirSync(innerAppPath, { recursive: true }); + execSync(`cd ${innerAppPath}`, execOptions); configurePython(); execSync("pipenv install django==3.2.23", execOptions); execSync( - `pipenv run django-admin startapp ${sanitizedName} ${basePath}`, + `pipenv run django-admin startapp ${sanitizedName} ${innerAppPath}`, execOptions ); + + const appsFileData = fs.readFileSync(`${innerAppPath}/apps.py`, "utf8"); + const result = appsFileData.replace(/name = '.*'/, `name = 'modules.django_${sanitizedName}.${sanitizedName}'`); + fs.writeFileSync(`${innerAppPath}/apps.py`, result, "utf8"); + fs.writeFileSync( path.join(base, relative, djangoName, "setup.py"), setupPy(sanitizedName), From 77118786dec49c4539f3def06c67c3a319bba327 Mon Sep 17 00:00:00 2001 From: "Robert So (robester0403)" <85914248+robester0403@users.noreply.github.com> Date: Fri, 9 Feb 2024 15:11:03 -0500 Subject: [PATCH 3/4] PLAT-13621 Removing all npx references on the documentation and Update package.json (#1093) * PLAT-13621 changed command name from crowdbotics-module to cb * PLAT-13621 changed command in console log and comments to cb * PLAT-13621 updated readme --------- Co-authored-by: Shashank Sinha Co-authored-by: Dan DeFelippi Co-authored-by: Crowdbotics --- README.md | 4 ++++ index.js | 32 ++++++++++++++++---------------- package.json | 2 +- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c11beff74..0840a06f3 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,10 @@ Create new modules and test/validate your work locally before submitting a PR: yarn run parse ``` +Install modules globally to your system: +```sh +npm install -g cb +``` ### macOS config - make sure to have a compatible version of urllib3 with openssl. urllib3 v2.0 or higher is compatible with OpenSSL 1.1.1 or higher diff --git a/index.js b/index.js index 77492efdf..6c19f270e 100755 --- a/index.js +++ b/index.js @@ -2,7 +2,7 @@ /** * Crowdbotics Modules tool * - * Run it anywhere with: npx crowdbotics/modules + * Run it anywhere with: cb * * Commands available: * - parse @@ -369,7 +369,7 @@ demo`; }, help: () => { - console.log(`usage: npx crowdbotics/modules + console.log(`usage: cb Commands available: parse Parse and validate your modules @@ -388,46 +388,46 @@ Commands available: modules Manage modules for your organization Parse and validate your modules: - npx crowdbotics/modules parse --source + cb parse --source Parse modules and write the data to a json file: - npx crowdbotics/modules parse --source --write + cb parse --source --write Create a demo app: - npx crowdbotics/modules demo + cb demo Create a module of a given type: - npx crowdbotics/modules create --name --type + cb create --name --type Initialize a modules repository: - npx crowdbotics/modules init --name + cb init --name Upgrade your scaffold to the latest master: - npx crowdbotics/modules upgrade + cb upgrade Upgrade your scaffold to a specific version (git tag, git commit or branch name): - npx crowdbotics/modules upgrade --version 2.3.0 + cb upgrade --version 2.3.0 Install one or modules to your demo app: - npx crowdbotics/modules add + cb add Remove one or modules from your demo app: - npx crowdbotics/modules remove + cb remove Install modules from other directory: - npx crowdbotics/modules add --source ../other-repository + cb add --source ../other-repository Install modules to other app that is not "demo": - npx crowdbotics/modules add --project ../other-project + cb add --project ../other-project Remove modules from app that is not "demo": - npx crowdbotics/modules remove --project ../other-project + cb remove --project ../other-project Update a module definition from the demo app: - npx crowdbotics/modules commit + cb commit Update a module definition from other app: - npx crowdbotics/modules commit --source + cb commit --source Glossary: stands for the name of the directory where the module is defined. diff --git a/package.json b/package.json index 01ad5510b..e4a1b41f8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Library of modules for React Native and Django apps", "main": "index.js", "bin": { - "crowdbotics-modules": "./index.js" + "cb": "./index.js" }, "type": "module", "engines": { From d6296f3414b55d6aa9950294251d94ff9f1f6333 Mon Sep 17 00:00:00 2001 From: taylor-cb <146020288+taylor-cb@users.noreply.github.com> Date: Mon, 12 Feb 2024 11:58:23 -0700 Subject: [PATCH 4/4] PLAT-13346: Adding central error handling for unauthenticated api client action (#1089) * PLAT-13346: Adding central error handling for unauthenticated api client action * PLAT-13346: Updating out of date error strings --- scripts/feedback.js | 8 +++----- scripts/logout.js | 4 +++- scripts/modules.js | 8 ++++---- scripts/publish.js | 2 +- scripts/utils/apiClient.js | 15 +++++++++++++-- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/scripts/feedback.js b/scripts/feedback.js index 5c5638272..a2d6fc4e3 100644 --- a/scripts/feedback.js +++ b/scripts/feedback.js @@ -11,12 +11,10 @@ export const sendFeedback = async (message) => { body: { message } }); - if (response.status === 401) { - return invalid("There was an error sending your feedback. Please ensure you have logged in with the 'login' command, then try again."); - } - if (!response.ok) { - return invalid("Unable to send feedback at this time, please try again."); + return invalid( + "Unable to send feedback at this time, please try again later." + ); } } catch (error) { return invalid(error); diff --git a/scripts/logout.js b/scripts/logout.js index 27453da8e..76ceb01f3 100644 --- a/scripts/logout.js +++ b/scripts/logout.js @@ -15,7 +15,9 @@ export const logout = async () => { }); configFile.save(); } catch (e) { - return invalid("An error occurred while logging out. Please try again"); + return invalid( + "An error occurred while logging out. Please try again later." + ); } valid("Logout successful"); }; diff --git a/scripts/modules.js b/scripts/modules.js index 32bff0cfa..7e1f1e0bc 100644 --- a/scripts/modules.js +++ b/scripts/modules.js @@ -50,7 +50,7 @@ export const modulesList = async ({ }); if (!response.ok) { - invalid("Failed to catalog modules. Please log in and try again."); + invalid("Failed to load catalog modules. Please try again later."); } const searchBody = await response.json(); @@ -93,7 +93,7 @@ export const modulesList = async ({ ); } } catch { - invalid("Unable to get modules. Please login and try again."); + invalid("Unable to get modules. Please try again later."); } finally { loadingSpinner.stop(); } @@ -112,7 +112,7 @@ export const modulesGet = async (id) => { if (moduleResponse.status === 404) { invalid(`Cannot find requested module with id ${id}.`); } else { - invalid("Unable to get module. Please login and try again."); + invalid("Unable to get module. Please try again later."); } return; @@ -148,7 +148,7 @@ export const modulesArchive = async (id, unarchive = false) => { if (moduleResponse.status === 404) { invalid(`Cannot find requested module with id ${id}.`); } else { - invalid("Unable to get module. Please login and try again."); + invalid("Unable to get module. Please try again later."); } return; diff --git a/scripts/publish.js b/scripts/publish.js index 5becea890..09fb734db 100644 --- a/scripts/publish.js +++ b/scripts/publish.js @@ -97,7 +97,7 @@ export const publish = async () => { defaultOrganization = userBody.organization; } } catch { - invalid("Unable to get current user. Please login and try again."); + invalid("Unable to get current user. Please try again later."); return; } finally { preparingSpinner.stop(); diff --git a/scripts/utils/apiClient.js b/scripts/utils/apiClient.js index 1b6129ba4..8c7e55a60 100644 --- a/scripts/utils/apiClient.js +++ b/scripts/utils/apiClient.js @@ -6,6 +6,7 @@ import { } from "./constants.js"; import fetch from "node-fetch"; import { formatUrlPath } from "./url.js"; +import { invalid } from "../../utils.js"; class ApiClient { get(options) { @@ -29,7 +30,7 @@ class ApiClient { return `Token ${token}`; } - _request({ path, body, method, params, anonymous }) { + async _request({ path, body, method, params, anonymous }) { const host = configFile.get(HOST_CONFIG_NAME) || DEFAULT_HOST; let url = `${formatUrlPath(host)}/api/${formatUrlPath(path)}/`; @@ -37,7 +38,7 @@ class ApiClient { url += "?" + new URLSearchParams(params).toString(); } - return fetch(url, { + const response = await fetch(url, { body: body ? JSON.stringify(body) : undefined, headers: { accept: "application/json", @@ -46,6 +47,16 @@ class ApiClient { }, method: method }); + + if (response.status === 401) { + // Flush newline before printing error in case console is in loading state. + console.log(""); + invalid( + "Invalid token. Please login and try again.\nRun `cb login` to get started." + ); + } + + return response; } }