Skip to content

Commit

Permalink
Add logging and local development server support
Browse files Browse the repository at this point in the history
This cleans up the auth commands a little bit, integrates `pino` for logging,
and adds a `--baseUrl` argument to `sindri login` which can be used for working
against a local development or staging environment.

Merges #13
  • Loading branch information
sangaline authored Nov 25, 2023
1 parent a46efd9 commit b991013
Show file tree
Hide file tree
Showing 10 changed files with 410 additions and 48 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ docker compose exec sindri-js bash

From here, you can use the `sindri` command or run any of the Yarn invocations for linting, formatting, etc.

#### Integrating With Local Development Server

Inside of the Docker container, you can run

```bash
sindri -d login -u http://host.docker.internal login
```

to authenticate against a Sindri development backend running locally on port 80.
Both your credentials and the local base URL for the server will be stored in `sindri.conf.json` and used in subsequent requests.

### Installing Dependencies

To install the project dependencies:
Expand Down
3 changes: 3 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ services:
UID: "${UID:-1000}"
command: ["/bin/sh", "-c", "yarn install && yarn build:watch"]
init: true
network_mode: host
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
volumes:
- ./:/sindri/
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,12 @@
"dependencies": {
"@inquirer/prompts": "^3.3.0",
"axios": "^1.6.2",
"chalk": "^4.1.2",
"commander": "^11.1.0",
"env-paths": "^2.2.1",
"form-data": "^4.0.0",
"lodash": "^4.17.21",
"pino": "^8.16.2",
"pino-pretty": "^10.2.3",
"rc": "^1.2.8",
"zod": "^3.22.4"
},
Expand Down
42 changes: 32 additions & 10 deletions src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@ import envPaths from "env-paths";
import { cloneDeep, merge } from "lodash";
import { z } from "zod";

import { logger } from "cli/logging";

const paths = envPaths("sindri", {
suffix: "",
});
const configPath = path.join(paths.config, "sindri.conf.json");
console.log(configPath);

const ConfigSchema = z.object({
auth: z
.nullable(
z.object({
apiKey: z.string(),
baseUrl: z.string().url(),
teamId: z.number(),
teamSlug: z.string(),
}),
Expand All @@ -29,19 +31,38 @@ const defaultConfig: ConfigSchema = ConfigSchema.parse({});

const loadConfig = (): ConfigSchema => {
if (fs.existsSync(configPath)) {
const configFileContents: string = fs.readFileSync(configPath, {
encoding: "utf-8",
});
return ConfigSchema.parse(JSON.parse(configFileContents));
logger.debug(`Loading config from "${configPath}".`);
try {
const configFileContents: string = fs.readFileSync(configPath, {
encoding: "utf-8",
});
const loadedConfig = ConfigSchema.parse(JSON.parse(configFileContents));
logger.debug("Config loaded successfully.");
return loadedConfig;
} catch (error) {
logger.warn(
`The config schema in "${configPath}" is invalid and will not be used.\n` +
`To remove it and start fresh, run:\n rm ${configPath}`,
);
logger.debug(error);
}
}
logger.debug(
`Config file "${configPath}" does not exist, initializing default config.`,
);
return cloneDeep(defaultConfig);
};

class Config {
protected _config: ConfigSchema;
export class Config {
protected _config!: ConfigSchema;
protected static instance: Config;

constructor() {
this._config = loadConfig();
if (!Config.instance) {
this._config = loadConfig();
Config.instance = this;
}
return Config.instance;
}

get auth(): ConfigSchema["auth"] {
Expand All @@ -50,6 +71,8 @@ class Config {

update(configData: Partial<ConfigSchema>) {
// Merge and validate the configs.
logger.debug("Merging in config update:");
logger.debug(configData);
const newConfig: ConfigSchema = cloneDeep(this._config);
merge(newConfig, configData);
this._config = ConfigSchema.parse(newConfig);
Expand All @@ -61,10 +84,9 @@ class Config {
}

// Write out the new config.
logger.debug(`Writing merged config to "${configPath}":`, this._config);
fs.writeFileSync(configPath, JSON.stringify(this._config, null, 2), {
encoding: "utf-8",
});
}
}

export const config = new Config();
34 changes: 32 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#! /usr/bin/env node
import { argv } from "process";
import { argv, exit } from "process";

import { Command } from "@commander-js/extra-typings";

import { Config } from "cli/config";
import { logger } from "cli/logging";
import { loginCommand } from "cli/login";
import { logoutCommand } from "cli/logout";
import { whoamiCommand } from "cli/whoami";
Expand All @@ -11,8 +13,36 @@ const program = new Command()
.name("sindri")
.description("The Sindri CLI client.")
.version(process.env.npm_package_version ?? "0.0.0")
.option("-d, --debug", "Enable debug logging.", false)
.option(
"-q, --quiet",
"Disable all logging aside from direct command outputs for programmatic consumption.",
false,
)
.addCommand(loginCommand)
.addCommand(logoutCommand)
.addCommand(whoamiCommand);
.addCommand(whoamiCommand)
// Parse the base command options and respond to them before invoking the subcommand.
.hook("preAction", async (command) => {
// Set the logging level.
const { debug, quiet } = command.opts();
if (debug && quiet) {
logger.error(
"You cannot specify both the `--debug` and `--quiet` arguments.",
);
return exit(1);
}
if (debug) {
logger.level = "trace";
} else if (quiet) {
logger.level = "silent";
} else {
logger.level = "info";
}
logger.debug(`Set log level to "${logger.level}".`);

// Force the loading of the config before subcommands.
new Config();
});

program.parse(argv);
14 changes: 14 additions & 0 deletions src/cli/logging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import pino from "pino";
import pretty from "pino-pretty";

const prettyStream = pretty({
colorize: true,
destination: 2,
ignore: "hostname,pid",
levelFirst: false,
sync: true,
});

export const logger = pino(prettyStream);

export const print = console.log;
61 changes: 44 additions & 17 deletions src/cli/login.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,63 @@
import os from "os";
import process from "process";

import axios from "axios";
import chalk from "chalk";
import { Command } from "@commander-js/extra-typings";
import { input, password as passwordInput, select } from "@inquirer/prompts";
import {
confirm,
input,
password as passwordInput,
select,
} from "@inquirer/prompts";

import { config } from "cli/config";
import { AuthorizationService, InternalService, TokenService } from "lib/api";
import { Config } from "cli/config";
import { logger } from "cli/logging";
import {
AuthorizationService,
InternalService,
OpenAPI,
TokenService,
} from "lib/api";

export const loginCommand = new Command()
.name("login")
.description("Authorize the client.")
.action(async () => {
.option(
"-u, --base-url <URL>",
"The base URL for the Sindri API. Mainly useful for development.",
OpenAPI.BASE,
)
.action(async ({ baseUrl }) => {
const config = new Config();
const auth = config.auth;
if (auth) {
const proceed = await confirm({
message:
`You are already logged in as ${auth.teamSlug} on ${auth.baseUrl}, ` +
"are you sure you want to proceed?",
default: false,
});
if (!proceed) {
logger.info("Aborting.");
return;
}
}
// Collect details for generating an API key.
const username = await input({ message: "Username:" });
const password = await passwordInput({ mask: true, message: "Password:" });
const name = await input({
default: `${os.hostname()}-sdk`,
message: "Key Name:",
message: "New API Key Name:",
});

// Generate an API key for one of their teams.
try {
// Generate a JWT token to authenticate the user.
OpenAPI.BASE = baseUrl;
const tokenResult = await TokenService.bf740E1AControllerObtainToken({
username,
password,
});
axios.defaults.headers.common = {
Authorization: `Bearer ${tokenResult.access}`,
};
OpenAPI.TOKEN = tokenResult.access;

// Fetch their teams and have the user select one.
const userResult = await InternalService.userMeWithJwtAuth();
Expand Down Expand Up @@ -57,14 +85,13 @@ export const loginCommand = new Command()
}

// Store the new auth information.
config.update({ auth: { apiKey, teamId, teamSlug: team.slug } });
console.log(
chalk.green(
"You have successfully authorized the client with your Sindri account.",
),
config.update({ auth: { apiKey, baseUrl, teamId, teamSlug: team.slug } });
logger.info(
"You have successfully authorized the client with your Sindri account.",
);
} catch (error) {
console.error(chalk.red("Something went wrong."));
console.error(error);
logger.fatal("An irrecoverable error occurred.");
logger.error(error);
process.exit(1);
}
});
11 changes: 6 additions & 5 deletions src/cli/logout.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import chalk from "chalk";
import { Command } from "@commander-js/extra-typings";

import { config } from "cli/config";
import { Config } from "cli/config";
import { logger } from "cli/logging";

export const logoutCommand = new Command()
.name("logout")
.description("Display the currently authorized team name.")
.description("Remove the current client authorization credentials.")
.action(async () => {
// Authorize the API client.
const config = new Config();
const auth = config.auth;
if (!auth) {
console.error(chalk.red("You must log in first with `sindri login`."));
logger.error("You must log in first with `sindri login`.");
return;
}

config.update({ auth: null });
console.log(chalk.green("You have successfully logged out."));
logger.info("You have successfully logged out.");
});
17 changes: 8 additions & 9 deletions src/cli/whoami.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
import axios from "axios";
import chalk from "chalk";
import process from "process";

import { Command } from "@commander-js/extra-typings";

import { config } from "cli/config";
import { Config } from "cli/config";
import { logger, print } from "cli/logging";

export const whoamiCommand = new Command()
.name("whoami")
.description("Display the currently authorized team name.")
.action(async () => {
// Authorize the API client.
const config = new Config();
const auth = config.auth;
if (!auth) {
console.error(chalk.red("You must login first with `sindri login`."));
return;
logger.warn("You must login first with `sindri login`.");
return process.exit(1);
}
axios.defaults.headers.common = {
Authorization: `Bearer ${auth.apiKey}`,
};

// TODO: Use the new "team-me" endpoint to test authentication and fetch the current team.
// This should be deployed soon, and we'll update this method then. For now, we just rely on
// whatever the team slug was when we logged in last.
console.log(auth.teamSlug);
print(auth.teamSlug);
});
Loading

0 comments on commit b991013

Please sign in to comment.