Skip to content

Commit

Permalink
feat(cli): implement add flag and config commands (#81)
Browse files Browse the repository at this point in the history
* feat(cli): add command to create a flag via the cli

* feat(cli): add command to create a remote config via the cli

* refactor(cli): use prompts instead of promptly for prompts

* test(cli): add tests for add flag and add config command

* docs(cli): add docs for new add commands

* feat(cli): do atomic update of config in add commands

* feat(cli): rollback old config when push fails after adding flag/config

* fix(cli): replace newlines only in the defineConfig part of a config file

* feat(cli): bump version

---------

Co-authored-by: Tim Raderschad <[email protected]>
  • Loading branch information
CakeWithDivinity and cstrnt authored Jan 7, 2024
1 parent e86aca4 commit 0ca1bd4
Show file tree
Hide file tree
Showing 14 changed files with 401 additions and 98 deletions.
25 changes: 25 additions & 0 deletions apps/docs/pages/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,31 @@ You will then need to delete it manually from your local config. **We are workin
| `-h`, `--host` | The API URL for the Abby instance. | `https://www.tryabby.com` ||
| `-c`, `--config` | The path to the config file. | The nearest `abby.config.(ts,js,mjs,cjs)` file ||

### `add flag`

Creates a flag, puts it into your `abby.config.ts` (or specified config file) and pushes the changes to the cloud.
If a flag with the name, which you have entered, already exists, this command will fail.

#### Options

| Flag | Description | Default | Required |
| ---------------- | ---------------------------------- | ---------------------------------------------- | -------- |
| `-h`, `--host` | The API URL for the Abby instance. | `https://www.tryabby.com` ||
| `-c`, `--config` | The path to the config file. | The nearest `abby.config.(ts,js,mjs,cjs)` file ||


### `add config`

Creates a remote config value, puts it into your `abby.config.ts` (or specified config file) and pushes the changes to the cloud.
If a remote config with the name, which you have entered, already exists, this command will fail.

#### Options

| Flag | Description | Default | Required |
| ---------------- | ---------------------------------- | ---------------------------------------------- | -------- |
| `-h`, `--host` | The API URL for the Abby instance. | `https://www.tryabby.com` ||
| `-c`, `--config` | The path to the config file. | The nearest `abby.config.(ts,js,mjs,cjs)` file ||

### `pull`

Pull the changes from the Abby API to your local config. It will merge the changes from the cloud with your local config.
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @tryabby/cli

## 1.1.0

### Minor Changes

- introduce new add commands

## 1.0.1

### Patch Changes
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tryabby/cli",
"version": "1.0.2",
"version": "1.1.0",
"private": false,
"main": "./dist/index.js",
"bin": {
Expand All @@ -27,11 +27,13 @@
"dotenv": "^16.0.3",
"esbuild": "0.18.17",
"figlet": "^1.6.0",
"magicast": "^0.3.2",
"msw": "^1.2.2",
"node-fetch": "^3.3.1",
"polka": "^0.5.2",
"portfinder": "^1.0.32",
"prettier": "^3.0.0",
"prompts": "^2.4.2",
"tsup": "^6.5.0",
"unconfig": "^0.3.10",
"vite": "^4.4.8",
Expand All @@ -43,6 +45,7 @@
"@types/figlet": "^1.5.6",
"@types/node": "^20.3.1",
"@types/polka": "^0.5.4",
"@types/prompts": "^2.4.5",
"nodemon": "^2.0.22",
"ts-node": "^10.9.1",
"tsconfig": "workspace:*",
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/add-flag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as fs from "fs/promises";
import { default as prompts } from "prompts";
import { loadLocalConfig } from "./util";
import chalk from "chalk";
import { push } from "./push";
import { updateConfigFile } from "./update-config-file";

export async function addFlag(options: { apiKey: string; host?: string; configPath?: string }) {
const { mutableConfig, saveMutableConfig, restoreConfig } = await loadLocalConfig(
options.configPath
);

const { flagName } = await prompts({
type: "text",
name: "flagName",
message: "Type the name for your new flag: ",
});

if (!mutableConfig.flags) {
mutableConfig.flags = [];
}

if (mutableConfig.flags.includes(flagName)) {
console.log(chalk.red("A flag with that name already exists!"));
return;
}

mutableConfig.flags.push(flagName);

try {
console.log(chalk.blue("Updating local config..."));
await saveMutableConfig();
console.log(chalk.green("Local config updated successfully"));

console.log(chalk.blue("Updating remote config..."));
await push({ apiKey: options.apiKey, configPath: options.configPath, apiUrl: options.host });
} catch (error) {
console.log(chalk.red("Pushing flag failed, restoring old config file..."));

await restoreConfig();

console.log(chalk.green("Restored old config file"));
// pass error to command handler
throw error;
}
}
70 changes: 70 additions & 0 deletions packages/cli/src/add-remote-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { loadLocalConfig } from "./util";
import chalk from "chalk";
import { push } from "./push";
import { default as prompts } from "prompts";
import { builders } from "magicast";

export async function addRemoteConfig(options: {
apiKey: string;
host?: string;
configPath?: string;
}) {
const { mutableConfig, saveMutableConfig, restoreConfig } = await loadLocalConfig(
options.configPath
);

const { remoteConfigName, remoteConfigType } = await prompts([
{
type: "text",
name: "remoteConfigName",
message: "Type the name for your new remote config: ",
},
{
type: "select",
name: "remoteConfigType",
message: "Select the type for your new remote config: ",
choices: [
{
title: "String",
value: "String",
},
{
title: "Number",
value: "Number",
},
{
title: "JSON",
value: "JSON",
},
],
},
]);

if (!mutableConfig.remoteConfig) {
mutableConfig.remoteConfig = builders.literal({});
}

if (remoteConfigName in mutableConfig.remoteConfig!) {
console.log(chalk.red("A remote config with that name already exists!"));
return;
}

mutableConfig.remoteConfig![remoteConfigName] = remoteConfigType;

try {
console.log(chalk.blue("Updating local config..."));
await saveMutableConfig();
console.log(chalk.green("Local config updated successfully"));

console.log(chalk.blue("Updating remote config..."));
await push({ apiKey: options.apiKey, configPath: options.configPath, apiUrl: options.host });
console.log(chalk.green("Remote config updated successfully"));
} catch (error) {
console.log(chalk.red("Pushing the configuration failed. Restoring old config file..."));
await restoreConfig();
console.log(chalk.green("Old config restored."));

// pass error to command handler
throw error;
}
}
15 changes: 8 additions & 7 deletions packages/cli/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,18 @@ export abstract class HttpService {

const status = response.status;

if (status == 200) {
if (status === 200) {
console.log(chalk.green("Config was pushed successfully"));
} else if (status == 500) {
console.log(chalk.red(multiLineLog("Push failed", "Please try again later")));
} else if (status == 401) {
console.log(chalk.red(multiLineLog("Push failed", "Please check your API key")));
} else if (status === 500) {
throw new Error("Internal server error while pushing config");
} else if (status === 401) {
throw new Error("Invalid API Key");
} else {
console.log(chalk.red(multiLineLog("Push failed")));
throw new Error("Push failed");
}
} catch (e) {
console.log("Error: " + e);
console.log(chalk.red(multiLineLog("Error: " + e)));
throw e;
}
}
}
42 changes: 42 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { push } from "./push";
import { ConfigOption, HostOption } from "./sharedOptions";
import { multiLineLog, startServerAndGetToken } from "./util";
import { initAbbyConfig } from "./init";
import { addCommandTypeSchema } from "./schemas";
import { addFlag } from "./add-flag";
import { addRemoteConfig } from "./add-remote-config";

const program = new Command();

Expand Down Expand Up @@ -87,6 +90,45 @@ program
}
});

program
.command("add")
.description("create a new flag or remote config both locally and remotely")
.argument("<entryType>", "Whether you want to create a `flag` or `config`")
.addOption(HostOption)
.addOption(ConfigOption)
.action(async (entryType, options: { configPath?: string; host?: string }) => {
let parsedEntryType = addCommandTypeSchema.safeParse(entryType);

if (!parsedEntryType.success) {
console.log(
chalk.red("Invalid type. Only `flag` or `config` are possible or leave the option empty")
);
return;
}

try {
const token = await getToken();
switch (parsedEntryType.data) {
case "flag":
await addFlag({ ...options, apiKey: token });
break;
case "config":
await addRemoteConfig({ ...options, apiKey: token });
break;
}
} catch (e) {
console.log(
chalk.red(
multiLineLog(
e instanceof Error
? e.message
: "Something went wrong. Please check your internet connection"
)
)
);
}
});

program
.command("check")
.description("check local config against server")
Expand Down
54 changes: 3 additions & 51 deletions packages/cli/src/pull.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,9 @@
import * as fs from "fs/promises";
import {
AbbyConfig,
PullAbbyConfigResponse,
DynamicConfigKeys,
DYNAMIC_ABBY_CONFIG_KEYS,
} from "@tryabby/core";
import { AbbyConfig, PullAbbyConfigResponse } from "@tryabby/core";
import { loadLocalConfig } from "./util";
import { HttpService } from "./http";
import deepmerge from "deepmerge";
import * as prettier from "prettier";

export function updateConfigFile(
updatedConfig: Omit<AbbyConfig, DynamicConfigKeys>,
configFileString: string
) {
// filter out keys that are marked as dynamic. Those are set in the
// first parameter of `defineConfig`, but we are only updating the
// second parameter.
updatedConfig = Object.fromEntries(
Object.entries(updatedConfig).filter(
([key]) => !(DYNAMIC_ABBY_CONFIG_KEYS as readonly string[]).includes(key)
)
) as Omit<AbbyConfig, DynamicConfigKeys>;

// remove new lines
configFileString = configFileString.replace(/(?:\r\n|\r|\n)/g, "");

const configRegex = /defineConfig.*, *({.*})/g;
const matchRegex = configRegex.exec(configFileString);
const matchedObject = matchRegex?.at(1);

if (!matchedObject) {
throw new Error("Invalid config file");
}

const updatedContent = configFileString.replace(
matchedObject,
JSON.stringify(updatedConfig, null, 2)
);

return updatedContent;
}
import { updateConfigFile } from "./update-config-file";

export function mergeConfigs(localConfig: AbbyConfig, remoteConfig: PullAbbyConfigResponse) {
return {
Expand Down Expand Up @@ -73,18 +36,7 @@ export async function pullAndMerge({

if (remoteConfig) {
const updatedConfig = mergeConfigs(localConfig, remoteConfig);
const updatedFileString = updateConfigFile(updatedConfig, configFileContents);

if (!updatedFileString) {
console.error("Config in file not found");
return;
}

const formattedFile = await prettier.format(updatedFileString, {
filepath: configFilePath,
});

await fs.writeFile(configFilePath, formattedFile);
await updateConfigFile(updatedConfig, configFileContents, configFilePath);
} else {
console.error("Config in file not found");
}
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { z } from "zod";

export const addCommandTypeSchema = z.enum(["flag", "config"]);
Loading

2 comments on commit 0ca1bd4

@vercel
Copy link

@vercel vercel bot commented on 0ca1bd4 Jan 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on 0ca1bd4 Jan 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.