Skip to content

Commit

Permalink
store deploy configuration in observable.config.ts (#431)
Browse files Browse the repository at this point in the history
Co-authored-by: Mike Bostock <[email protected]>
  • Loading branch information
mythmon and mbostock authored Jan 4, 2024
1 parent 3f7b16a commit 8ac8fda
Show file tree
Hide file tree
Showing 13 changed files with 454 additions and 277 deletions.
150 changes: 80 additions & 70 deletions bin/observable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import {type ParseArgsConfig, parseArgs} from "node:util";
import {readConfig} from "../src/config.js";
import {CliError} from "../src/error.js";
import {red} from "../src/tty.js";

const args = process.argv.slice(2);

Expand Down Expand Up @@ -54,86 +56,94 @@ else if (values.help) {
command = "help";
}

switch (command) {
case undefined:
case "help": {
helpArgs(command, {allowPositionals: true});
console.log(
`usage: observable <command>
try {
switch (command) {
case undefined:
case "help": {
helpArgs(command, {allowPositionals: true});
console.log(
`usage: observable <command>
preview start the preview server
build generate a static site
login sign-in to Observable
deploy deploy a project to Observable
whoami check authentication status
help print usage information
version print the version`
);
if (command === undefined) process.exit(1);
break;
}
case "version": {
helpArgs(command, {});
await import("../package.json").then(({version}: any) => console.log(version));
break;
}
case "build": {
const {
values: {config, root}
} = helpArgs(command, {
options: {...CONFIG_OPTION}
});
await import("../src/build.js").then(async (build) => build.build({config: await readConfig(config, root)}));
break;
}
case "deploy": {
const {
values: {config, root}
} = helpArgs(command, {
options: {...CONFIG_OPTION}
});
await import("../src/deploy.js").then(async (deploy) => deploy.deploy({config: await readConfig(config, root)}));
break;
}
case "preview": {
const {
values: {config, root, host, port}
} = helpArgs(command, {
options: {
...CONFIG_OPTION,
host: {
type: "string",
default: process.env.HOSTNAME ?? "127.0.0.1"
},
port: {
type: "string",
default: process.env.PORT
);
if (command === undefined) process.exit(1);
break;
}
case "version": {
helpArgs(command, {});
await import("../package.json").then(({version}: any) => console.log(version));
break;
}
case "build": {
const {
values: {config, root}
} = helpArgs(command, {
options: {...CONFIG_OPTION}
});
await import("../src/build.js").then(async (build) => build.build({config: await readConfig(config, root)}));
break;
}
case "deploy": {
const {
values: {config, root}
} = helpArgs(command, {
options: {...CONFIG_OPTION}
});
await import("../src/deploy.js").then(async (deploy) => deploy.deploy({config: await readConfig(config, root)}));
break;
}
case "preview": {
const {
values: {config, root, host, port}
} = helpArgs(command, {
options: {
...CONFIG_OPTION,
host: {
type: "string",
default: process.env.HOSTNAME ?? "127.0.0.1"
},
port: {
type: "string",
default: process.env.PORT
}
}
}
});
await import("../src/preview.js").then(async (preview) =>
preview.preview({
config: await readConfig(config, root),
hostname: host!,
port: port === undefined ? undefined : +port
})
);
break;
}
case "login": {
helpArgs(command, {});
await import("../src/observableApiAuth.js").then((auth) => auth.login());
break;
}
case "whoami": {
helpArgs(command, {});
await import("../src/observableApiAuth.js").then((auth) => auth.whoami());
break;
});
await import("../src/preview.js").then(async (preview) =>
preview.preview({
config: await readConfig(config, root),
hostname: host!,
port: port === undefined ? undefined : +port
})
);
break;
}
case "login": {
helpArgs(command, {});
await import("../src/observableApiAuth.js").then((auth) => auth.login());
break;
}
case "whoami": {
helpArgs(command, {});
await import("../src/observableApiAuth.js").then((auth) => auth.whoami());
break;
}
default: {
console.error(`observable: unknown command '${command}'. See 'observable help'.`);
process.exit(1);
break;
}
}
default: {
console.error(`observable: unknown command '${command}'. See 'observable help'.`);
process.exit(1);
break;
} catch (error) {
if (error instanceof CliError) {
if (error.print) console.error(red(error.message));
process.exit(error.exitCode);
}
throw error;
}

// A wrapper for parseArgs that adds --help functionality with automatic usage.
Expand Down
6 changes: 4 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface Config {
pager: boolean; // defaults to true
toc: TableOfContents;
style: string; // defaults to default stylesheet
deploy: null | {workspace: string; project: string};
}

export async function readConfig(configPath?: string, root?: string): Promise<Config> {
Expand Down Expand Up @@ -61,7 +62,7 @@ async function readPages(root: string): Promise<Page[]> {
}

export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Promise<Config> {
let {root = defaultRoot, output = "dist", style = getClientPath("./src/style/index.css")} = spec;
let {root = defaultRoot, output = "dist", style = getClientPath("./src/style/index.css"), deploy} = spec;
root = String(root);
output = String(output);
style = String(style);
Expand All @@ -70,7 +71,8 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro
pages = Array.from(pages, normalizePageOrSection);
pager = Boolean(pager);
toc = normalizeToc(toc);
return {root, output, title, pages, pager, toc, style};
deploy = deploy ? {workspace: String(deploy.workspace), project: String(deploy.project)} : null;
return {root, output, title, pages, pager, toc, style, deploy};
}

function normalizePageOrSection(spec: any): Page | Section {
Expand Down
103 changes: 33 additions & 70 deletions src/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import readline from "node:readline/promises";
import {isatty} from "node:tty";
import type {BuildEffects} from "./build.js";
import {build} from "./build.js";
import type {Config} from "./config.js";
import {CliError} from "./error.js";
import type {Logger, Writer} from "./logger.js";
import {ObservableApiClient} from "./observableApiClient.js";
import {
Expand All @@ -21,6 +23,7 @@ export interface DeployEffects {
getObservableApiKey: (logger: Logger) => Promise<ApiKey>;
getDeployConfig: (sourceRoot: string) => Promise<DeployConfig | null>;
setDeployConfig: (sourceRoot: string, config: DeployConfig) => Promise<void>;
isTty: boolean;
logger: Logger;
input: NodeJS.ReadableStream;
output: NodeJS.WritableStream;
Expand All @@ -30,6 +33,7 @@ const defaultEffects: DeployEffects = {
getObservableApiKey,
getDeployConfig,
setDeployConfig,
isTty: isatty(process.stdin.fd),
logger: console,
input: process.stdin,
output: process.stdout
Expand All @@ -41,56 +45,48 @@ export async function deploy({config}: DeployOptions, effects = defaultEffects):
const apiKey = await effects.getObservableApiKey(logger);
const apiClient = new ObservableApiClient({apiKey});

// Find the existing project or create a new one.
const sourceRoot = config.root;
const deployConfig = await effects.getDeployConfig(sourceRoot);
let projectId = deployConfig?.project?.id;
if (projectId) {
logger.log(`Found existing project ${projectId}`);
} else {
logger.log("Creating a new project");
const currentUserResponse = await apiClient.getCurrentUser();

const title = await promptUserForInput(effects.input, effects.output, "New project title: ");
const defaultSlug = slugify(title);
const slug = await promptUserForInput(
effects.input,
effects.output,
`New project slug [${defaultSlug}]: `,
defaultSlug
// Check configuration
if (!config.deploy) {
throw new CliError(
"You haven't configured a project to deploy to. Please set deploy.workspace and deploy.project in your configuration."
);
}

let workspaceId: string | null = null;
if (currentUserResponse.workspaces.length == 0) {
logger.error("Current user doesn't have any Observable workspaces!");
return;
} else if (currentUserResponse.workspaces.length == 1) {
workspaceId = currentUserResponse.workspaces[0].id;
} else {
const workspaceNames = currentUserResponse.workspaces.map((x) => x.name);
const index = await promptUserForChoiceIndex(
// Check last deployed state. If it's not the same project, ask the user if
// they want to continue anyways. In non-interactive mode just cancel.
const projectInfo = await apiClient.getProject({
workspaceLogin: config.deploy.workspace,
projectSlug: config.deploy.project
});
const deployConfig = await effects.getDeployConfig(config.root);
const previousProjectId = deployConfig?.projectId;
if (previousProjectId && previousProjectId !== projectInfo.id) {
logger.log(
`The project @${config.deploy.workspace}/${config.deploy.project} does not match the expected project in ${config.root}/.observablehq/deploy.json`
);
if (effects.isTty) {
const choice = await promptUserForInput(
effects.input,
effects.output,
"Available Workspaces",
workspaceNames
"Do you want to update the expected project and deploy anyways? [y/N]"
);
workspaceId = currentUserResponse.workspaces[index].id;
if (choice.trim().toLowerCase().charAt(0) !== "y") {
throw new CliError("User cancelled deploy.", {print: false, exitCode: 2});
}
} else {
throw new CliError("Cancelling deploy due to misconfiguration.");
}

const project = await apiClient.postProject({slug, title, workspaceId});
projectId = project.id;
await effects.setDeployConfig(sourceRoot, {project: {id: projectId, slug, workspace: workspaceId}});
logger.log(`Created new project ${project.owner.login}/${project.slug}`);
}
await effects.setDeployConfig(config.root, {projectId: projectInfo.id});

// Create the new deploy.
// Create the new deploy on the server
const message = await promptUserForInput(effects.input, effects.output, "Deploy message: ");
const deployId = await apiClient.postDeploy({projectId, message});
const deployId = await apiClient.postDeploy({projectId: projectInfo.id, message});

// Build the project
await build({config, clientEntry: "./src/client/deploy.js"}, new DeployBuildEffects(apiClient, deployId, effects));

// Mark the deploy as uploaded.
// Mark the deploy as uploaded
const deployInfo = await apiClient.postDeployUploaded(deployId);
logger.log(`Deployed project now visible at ${blue(deployInfo.url)}`);
}
Expand All @@ -114,30 +110,6 @@ async function promptUserForInput(
}
}

async function promptUserForChoiceIndex(
input: NodeJS.ReadableStream,
output: NodeJS.WritableStream,
title: string,
choices: string[]
): Promise<number> {
const validChoices: string[] = [];
const promptLines: string[] = ["", title, "=".repeat(title.length)];
for (let i = 0; i < choices.length; i++) {
validChoices.push(`${i + 1}`);
promptLines.push(`${i + 1}. ${choices[i]}`);
}
const question = promptLines.join("\n") + "\nChoice: ";
const rl = readline.createInterface({input, output});
try {
let value: string | null = null;
do value = await rl.question(question);
while (!validChoices.includes(value));
return parseInt(value) - 1; // zero-indexed
} finally {
rl.close();
}
}

class DeployBuildEffects implements BuildEffects {
readonly logger: Logger;
readonly output: Writer;
Expand All @@ -158,12 +130,3 @@ class DeployBuildEffects implements BuildEffects {
await this.apiClient.postDeployFileContents(this.deployId, content, outputPath);
}
}

function slugify(s: string): string {
return s
.toLowerCase()
.replace("'", "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.replace(/-{2,}/g, "-");
}
Loading

0 comments on commit 8ac8fda

Please sign in to comment.