Skip to content

Commit

Permalink
Merge pull request #320 from mittwald/bugfix/ddev-auth-init
Browse files Browse the repository at this point in the history
Initialize mittwald API token on `ddev init`
  • Loading branch information
martin-helmich authored Mar 12, 2024
2 parents e749185 + 249bb5b commit 0ca4f53
Show file tree
Hide file tree
Showing 9 changed files with 282 additions and 157 deletions.
42 changes: 3 additions & 39 deletions src/BaseCommand.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Command } from "@oclif/core";
import * as fs from "fs/promises";
import * as path from "path";
import { MittwaldAPIV2Client } from "@mittwald/api-client";
import { configureAxiosRetry } from "./lib/api_retry.js";
import { configureConsistencyHandling } from "./lib/api_consistency.js";
import { getTokenFilename, readApiToken } from "./lib/auth/token.js";

export abstract class BaseCommand extends Command {
protected authenticationRequired = true;
Expand All @@ -13,10 +12,10 @@ export abstract class BaseCommand extends Command {
public async init(): Promise<void> {
await super.init();
if (this.authenticationRequired) {
const token = await this.readApiToken();
const token = await readApiToken(this.config);
if (token === undefined) {
throw new Error(
`Could not get token from either config file (${this.getTokenFilename()}) or environment`,
`Could not get token from either config file (${getTokenFilename(this.config)}) or environment`,
);
}

Expand All @@ -28,39 +27,4 @@ export abstract class BaseCommand extends Command {
configureConsistencyHandling(this.apiClient.axios);
}
}

protected getTokenFilename(): string {
return path.join(this.config.configDir, "token");
}

private async readApiToken(): Promise<string | undefined> {
return (
this.readApiTokenFromEnvironment() ??
(await this.readApiTokenFromConfig())
);
}

private readApiTokenFromEnvironment(): string | undefined {
const token = process.env.MITTWALD_API_TOKEN;
if (token === undefined) {
return undefined;
}
return token.trim();
}

private async readApiTokenFromConfig(): Promise<string | undefined> {
try {
const tokenFileContents = await fs.readFile(
this.getTokenFilename(),
"utf-8",
);
return tokenFileContents.trim();
} catch (err) {
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
return undefined;
}

throw err;
}
}
}
155 changes: 59 additions & 96 deletions src/commands/ddev/init.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,31 @@ import {
makeProcessRenderer,
processFlags,
} from "../../rendering/process/process_flags.js";
import { mkdir, writeFile } from "fs/promises";
import { mkdir, readFile, writeFile } from "fs/promises";
import path from "path";
import { DDEVConfigBuilder } from "../../lib/ddev/config_builder.js";
import { spawnInProcess } from "../../rendering/process/process_exec.js";
import { Flags } from "@oclif/core";
import { DDEVInitSuccess } from "../../rendering/react/components/DDEV/DDEVInitSuccess.js";
import { DDEVConfig, ddevConfigToFlags } from "../../lib/ddev/config.js";
import { hasBinary } from "../../lib/hasbin.js";
import { ProcessRenderer } from "../../rendering/process/process.js";
import { renderDDEVConfig } from "../../lib/ddev/config_render.js";
import { loadDDEVConfig } from "../../lib/ddev/config_loader.js";
import { Value } from "../../rendering/react/components/Value.js";
import { ddevFlags } from "../../lib/ddev/flags.js";
import { exec } from "child_process";
import { promisify } from "util";
import { compareSemVer } from "semver-parser";
import { assertStatus, type MittwaldAPIV2 } from "@mittwald/api-client";
import { Text } from "ink";
import { readApiToken } from "../../lib/auth/token.js";
import { isNotFound } from "../../lib/fsutil.js";
import { dump, load } from "js-yaml";
import { determineDDEVDatabaseId } from "../../lib/ddev/init_database.js";
import {
assertDDEVIsInstalled,
determineDDEVVersion,
} from "../../lib/ddev/init_assert.js";

type AppInstallation = MittwaldAPIV2.Components.Schemas.AppAppInstallation;

const execAsync = promisify(exec);

export class Init extends ExecRenderBaseCommand<typeof Init, void> {
static summary = "Initialize a new ddev project in the current directory.";
static description =
Expand Down Expand Up @@ -70,9 +72,15 @@ export class Init extends ExecRenderBaseCommand<typeof Init, void> {

await assertDDEVIsInstalled(r);

const ddevVersion = await this.determineDDEVVersion(r);
const ddevVersion = await determineDDEVVersion(r);
const appInstallation = await this.getAppInstallation(r, appInstallationId);
const databaseId = await this.determineDatabaseId(r, appInstallation);
const databaseId = await determineDDEVDatabaseId(
r,
this.apiClient,
this.flags,
appInstallation,
);
await this.writeAuthConfiguration(r);
const config = await this.writeMittwaldConfiguration(
r,
appInstallationId,
Expand All @@ -98,15 +106,6 @@ export class Init extends ExecRenderBaseCommand<typeof Init, void> {
]);
}

private async determineDDEVVersion(r: ProcessRenderer): Promise<string> {
const { stdout } = await execAsync("ddev --version");
const version = stdout.trim().replace(/^ddev version +/, "");

r.addInfo(<InfoDDEVVersion version={version} />);

return version;
}

private async getAppInstallation(
r: ProcessRenderer,
appInstallationId: string,
Expand Down Expand Up @@ -166,60 +165,48 @@ export class Init extends ExecRenderBaseCommand<typeof Init, void> {
return await r.addInput("Enter the project name", false);
}

private async determineDatabaseId(
r: ProcessRenderer,
appInstallation: AppInstallation,
): Promise<string | undefined> {
let databaseId: string | undefined = this.flags["database-id"];
const withoutDatabase = this.flags["without-database"];

if (withoutDatabase) {
return undefined;
}

if (databaseId === undefined) {
databaseId = (appInstallation.linkedDatabases ?? []).find(
(db) => db.purpose === "primary",
)?.databaseId;
}

if (databaseId !== undefined) {
const mysqlDatabaseResponse =
await this.apiClient.database.getMysqlDatabase({
mysqlDatabaseId: databaseId,
});
assertStatus(mysqlDatabaseResponse, 200);

r.addInfo(<InfoDatabase name={mysqlDatabaseResponse.data.name} />);
return mysqlDatabaseResponse.data.name;
}

return await this.promptDatabaseFromUser(r, appInstallation);
}

private async promptDatabaseFromUser(
r: ProcessRenderer,
appInstallation: AppInstallation,
): Promise<string | undefined> {
const { projectId } = appInstallation;
if (!projectId) {
throw new Error("app installation has no project ID");
}

const mysqlDatabaseResponse =
await this.apiClient.database.listMysqlDatabases({ projectId });
assertStatus(mysqlDatabaseResponse, 200);

return await r.addSelect("select the database to use", [
...mysqlDatabaseResponse.data.map((db) => ({
value: db.name,
label: `${db.name} (${db.description})`,
})),
{
value: undefined,
label: "no database",
},
]);
/**
* This steps writes the users API token to the local DDEV configuration file.
* This is necessary to authenticate the DDEV project with the mittwald API.
*
* The token is written to the `web_environment` section of the
* `config.local.yaml`, which _should_ be safe to store credentials in, as it
* is in DDEV's default `.gitignore` file.
*/
private async writeAuthConfiguration(r: ProcessRenderer) {
// NOTE that config.local.yaml is in DDEV's default .gitignore file, so
// it *should* be safe to store credentials in there.
const configFile = path.join(".ddev", "config.local.yaml");
const token = await readApiToken(this.config);

await r.runStep("writing local-only DDEV configuration", async () => {
try {
const existing = await readFile(configFile, { encoding: "utf-8" });
const parsed = load(existing) as Partial<DDEVConfig>;

const alreadyContainsAPIToken = (parsed.web_environment ?? []).some(
(e) => e.startsWith("MITTWALD_API_TOKEN="),
);
if (!alreadyContainsAPIToken) {
parsed.web_environment = [
...(parsed.web_environment ?? []),
`MITTWALD_API_TOKEN=${token}`,
];
await writeContentsToFile(configFile, dump(parsed));
}
} catch (err) {
if (isNotFound(err)) {
const config: Partial<DDEVConfig> = {
web_environment: [`MITTWALD_API_TOKEN=${token}`],
};

await writeContentsToFile(configFile, dump(config));
return;
} else {
throw err;
}
}
});
}

private async writeMittwaldConfiguration(
Expand Down Expand Up @@ -250,14 +237,6 @@ export class Init extends ExecRenderBaseCommand<typeof Init, void> {
}
}

async function assertDDEVIsInstalled(r: ProcessRenderer): Promise<void> {
await r.runStep("check if DDEV is installed", async () => {
if (!(await hasBinary("ddev"))) {
throw new Error("this command requires DDEV to be installed");
}
});
}

async function writeContentsToFile(
filename: string,
data: string,
Expand All @@ -275,19 +254,3 @@ function InfoUsingExistingName({ name }: { name: string }) {
</>
);
}

function InfoDDEVVersion({ version }: { version: string }) {
return (
<>
detected DDEV version: <Value>{version}</Value>
</>
);
}

function InfoDatabase({ name }: { name: string }) {
return (
<Text>
using database: <Value>{name}</Value>
</Text>
);
}
19 changes: 11 additions & 8 deletions src/commands/login/reset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Box, Text } from "ink";
import { Note } from "../../rendering/react/components/Note.js";
import { FancyProcessRenderer } from "../../rendering/process/process_fancy.js";
import { Filename } from "../../rendering/react/components/Filename.js";
import { getTokenFilename } from "../../lib/auth/token.js";
import { isNotFound } from "../../lib/fsutil.js";

type ResetResult = { deleted: boolean };

Expand All @@ -17,19 +19,20 @@ export default class Reset extends ExecRenderBaseCommand<

protected async exec(): Promise<ResetResult> {
const process = new FancyProcessRenderer("Resetting authentication state");
const tokenFilename = getTokenFilename(this.config);

process.start();

if (await this.tokenFileExists()) {
if (await this.tokenFileExists(tokenFilename)) {
const step = process.addStep(
<Text>
Deleting token file <Filename filename={this.getTokenFilename()} />
Deleting token file <Filename filename={tokenFilename} />
</Text>,
);
await fs.rm(this.getTokenFilename(), { force: true });
await fs.rm(tokenFilename, { force: true });
step.complete();

process.complete(
await process.complete(
<Box flexDirection="column">
<Text>Authentication state successfully reset</Text>
<Note>
Expand All @@ -43,7 +46,7 @@ export default class Reset extends ExecRenderBaseCommand<
return { deleted: true };
}

process.complete(<Text>No token file found, nothing to do</Text>);
await process.complete(<Text>No token file found, nothing to do</Text>);

return { deleted: false };
}
Expand All @@ -52,12 +55,12 @@ export default class Reset extends ExecRenderBaseCommand<
return null;
}

private async tokenFileExists(): Promise<boolean> {
private async tokenFileExists(tokenFilename: string): Promise<boolean> {
try {
await fs.access(this.getTokenFilename());
await fs.access(tokenFilename);
return true;
} catch (err) {
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
if (isNotFound(err)) {
return false;
}
throw err;
Expand Down
12 changes: 8 additions & 4 deletions src/commands/login/token.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Flags, ux } from "@oclif/core";
import { BaseCommand } from "../../BaseCommand.js";
import * as fs from "fs/promises";
import { getTokenFilename } from "../../lib/auth/token.js";
import { isNotFound } from "../../lib/fsutil.js";

export default class Token extends BaseCommand {
static description = "Authenticate using an API token";
Expand Down Expand Up @@ -28,18 +30,20 @@ export default class Token extends BaseCommand {
type: "mask",
});

const tokenFilename = getTokenFilename(this.config);

await fs.mkdir(this.config.configDir, { recursive: true });
await fs.writeFile(this.getTokenFilename(), token, "utf-8");
await fs.writeFile(tokenFilename, token, "utf-8");

this.log("token saved to %o", this.getTokenFilename());
this.log("token saved to %o", tokenFilename);
}

private async tokenFileExists(): Promise<boolean> {
try {
await fs.access(this.getTokenFilename());
await fs.access(getTokenFilename(this.config));
return true;
} catch (err) {
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
if (isNotFound(err)) {
return false;
}
throw err;
Expand Down
Loading

0 comments on commit 0ca4f53

Please sign in to comment.