Skip to content

Commit 708a64f

Browse files
authored
make deploy logging prettier and more helpful (#539)
1 parent 9324621 commit 708a64f

File tree

5 files changed

+276
-67
lines changed

5 files changed

+276
-67
lines changed

src/deploy.ts

Lines changed: 75 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
setDeployConfig
1515
} from "./observableApiConfig.js";
1616
import {Telemetry} from "./telemetry.js";
17-
import {blue} from "./tty.js";
17+
import {blue, bold, hangingIndentLog, magenta, yellow} from "./tty.js";
1818

1919
export interface DeployOptions {
2020
config: Config;
@@ -28,6 +28,7 @@ export interface DeployEffects {
2828
logger: Logger;
2929
input: NodeJS.ReadableStream;
3030
output: NodeJS.WritableStream;
31+
outputColumns: number;
3132
}
3233

3334
const defaultEffects: DeployEffects = {
@@ -37,7 +38,8 @@ const defaultEffects: DeployEffects = {
3738
isTty: isatty(process.stdin.fd),
3839
logger: console,
3940
input: process.stdin,
40-
output: process.stdout
41+
output: process.stdout,
42+
outputColumns: process.stdout.columns ?? 80
4143
};
4244

4345
/** Deploy a project to ObservableHQ */
@@ -84,56 +86,78 @@ export async function deploy({config}: DeployOptions, effects = defaultEffects):
8486
}
8587
}
8688

89+
const deployConfig = await effects.getDeployConfig(config.root);
8790
if (projectId) {
8891
// Check last deployed state. If it's not the same project, ask the user if
8992
// they want to continue anyways. In non-interactive mode just cancel.
90-
const deployConfig = await effects.getDeployConfig(config.root);
9193
const previousProjectId = deployConfig?.projectId;
9294
if (previousProjectId && previousProjectId !== projectId) {
93-
logger.log(
94-
`This project was last deployed to a workspace/slug different from @${config.deploy.workspace}/${config.deploy.project}.`
95+
const {indent} = hangingIndentLog(
96+
effects,
97+
magenta("Attention:"),
98+
`This project was last deployed to a different project on Observable Cloud from ${bold(
99+
`@${config.deploy.workspace}/${config.deploy.project}`
100+
)}.`
95101
);
96102
if (effects.isTty) {
97-
const choice = await promptUserForInput(
98-
effects.input,
99-
effects.output,
100-
`Do you want to deploy to @${config.deploy.workspace}/${config.deploy.project} anyway? [y/N]`
101-
);
102-
if (choice.trim().toLowerCase().charAt(0) !== "y") {
103-
throw new CliError("User cancelled deploy.", {print: false, exitCode: 2});
103+
const choice = await promptConfirm(effects, `${indent}Do you want to deploy anyway?`, {default: false});
104+
if (!choice) {
105+
throw new CliError("User cancelled deploy", {print: false, exitCode: 0});
104106
}
105107
} else {
106108
throw new CliError("Cancelling deploy due to misconfiguration.");
107109
}
110+
} else if (!previousProjectId) {
111+
const {indent} = hangingIndentLog(
112+
effects,
113+
yellow("Warning:"),
114+
`There is an existing project on Observable Cloud named ${bold(
115+
`@${config.deploy.workspace}/${config.deploy.project}`
116+
)} that is not associated with this repository. If you continue, you'll overwrite the existing content of the project.`
117+
);
118+
119+
if (!(await promptConfirm(effects, `${indent}Do you want to continue?`, {default: false}))) {
120+
if (effects.isTty) {
121+
throw new CliError("Running non-interactively, cancelling deploy", {print: true, exitCode: 1});
122+
} else {
123+
throw new CliError("User cancelled deploy", {print: true, exitCode: 0});
124+
}
125+
}
108126
}
109127
} else {
110128
// Project doesn't exist, so ask the user if they want to create it.
111-
// In non-interactive mode just cancel.
129+
const {indent} = hangingIndentLog(
130+
effects,
131+
magenta("Attention:"),
132+
`There is no project on the Observable Cloud named ${bold(
133+
`@${config.deploy.workspace}/${config.deploy.project}`
134+
)}`
135+
);
112136
if (effects.isTty) {
113-
const choice = await promptUserForInput(effects.input, effects.output, "No project exists. Create it now? [y/N]");
114-
if (choice.trim().toLowerCase().charAt(0) !== "y") {
115-
throw new CliError("User cancelled deploy.", {print: false, exitCode: 2});
116-
}
117137
if (!config.title) {
118138
throw new CliError("You haven't configured a project title. Please set title in your configuration.");
119139
}
120-
const currentUserResponse = await apiClient.getCurrentUser();
121-
const workspace = currentUserResponse.workspaces.find((w) => w.login === config.deploy?.workspace);
122-
if (!workspace) {
123-
const availableWorkspaces = currentUserResponse.workspaces.map((w) => w.login).join(", ");
124-
throw new CliError(
125-
`Workspace ${config.deploy?.workspace} not found. Available workspaces: ${availableWorkspaces}.`
126-
);
140+
if (!(await promptConfirm(effects, `${indent}Do you want to create it now?`, {default: false}))) {
141+
throw new CliError("User cancelled deploy.", {print: false, exitCode: 0});
127142
}
128-
const project = await apiClient.postProject({
129-
slug: config.deploy.project,
130-
title: config.title,
131-
workspaceId: workspace.id
132-
});
133-
projectId = project.id;
134143
} else {
135144
throw new CliError("Cancelling deploy due to non-existent project.");
136145
}
146+
147+
const currentUserResponse = await apiClient.getCurrentUser();
148+
const workspace = currentUserResponse.workspaces.find((w) => w.login === config.deploy?.workspace);
149+
if (!workspace) {
150+
const availableWorkspaces = currentUserResponse.workspaces.map((w) => w.login).join(", ");
151+
throw new CliError(
152+
`Workspace ${config.deploy?.workspace} not found. Available workspaces: ${availableWorkspaces}.`
153+
);
154+
}
155+
const project = await apiClient.postProject({
156+
slug: config.deploy.project,
157+
title: config.title,
158+
workspaceId: workspace.id
159+
});
160+
projectId = project.id;
137161
}
138162

139163
await effects.setDeployConfig(config.root, {projectId});
@@ -170,6 +194,27 @@ async function promptUserForInput(
170194
}
171195
}
172196

197+
export async function promptConfirm(
198+
{input, output}: DeployEffects,
199+
question: string,
200+
opts: {default: boolean}
201+
): Promise<boolean> {
202+
const rl = readline.createInterface({input, output});
203+
const choices = opts.default ? "[Y/n]" : "[y/N]";
204+
try {
205+
let value: string | null = null;
206+
while (true) {
207+
value = (await rl.question(`${question} ${choices} `)).toLowerCase();
208+
if (value === "") return opts.default;
209+
if (value.startsWith("y")) return true;
210+
if (value.startsWith("n")) return false;
211+
rl.write('Please answer "y" or "n".\n');
212+
}
213+
} finally {
214+
rl.close();
215+
}
216+
}
217+
173218
class DeployBuildEffects implements BuildEffects {
174219
readonly logger: Logger;
175220
readonly output: Writer;

src/tty.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type {Logger} from "./logger.js";
2+
13
export const bold = color(1, 22);
24
export const faint = color(2, 22);
35
export const italic = color(3, 23);
@@ -14,3 +16,47 @@ export type TtyColor = (text: string) => string;
1416
function color(code: number, reset: number): TtyColor {
1517
return process.stdout.isTTY ? (text: string) => `\x1b[${code}m${text}\x1b[${reset}m` : String;
1618
}
19+
20+
export interface TtyEffects {
21+
isTty: boolean;
22+
logger: Logger;
23+
outputColumns: number;
24+
}
25+
26+
function stripColor(s: string): string {
27+
// eslint-disable-next-line no-control-regex
28+
return s.replace(/\x1b\[[0-9;]*m/g, "");
29+
}
30+
31+
export function hangingIndentLog(
32+
effects: TtyEffects,
33+
prefix: string,
34+
message: string
35+
): {output: string; indent: string} {
36+
let output;
37+
let indent;
38+
if (effects.isTty) {
39+
const prefixLength = stripColor(prefix).length + 1;
40+
const lineBudget = effects.outputColumns - prefixLength;
41+
const tokens = message.split(" ");
42+
const lines: string[][] = [[]];
43+
indent = " ".repeat(prefixLength);
44+
let lastLineLength = 0;
45+
for (const token of tokens) {
46+
const tokenLength = stripColor(token).length;
47+
lastLineLength += tokenLength + 1;
48+
if (lastLineLength > lineBudget) {
49+
lines.push([]);
50+
lastLineLength = tokenLength;
51+
}
52+
lines.at(-1)?.push(token);
53+
}
54+
output = prefix + " ";
55+
output += lines.map((line) => line.join(" ")).join("\n" + indent);
56+
} else {
57+
output = `${prefix} ${message}`;
58+
indent = "";
59+
}
60+
effects.logger.log(output);
61+
return {output, indent};
62+
}

0 commit comments

Comments
 (0)