Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adopt clack #627

Merged
merged 14 commits into from
Jan 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions bin/observable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,8 @@ try {
break;
}
case "create": {
const {
positionals: [output]
} = helpArgs(command, {allowPositionals: true});
// TODO error if more than one positional
await import("../src/create.js").then(async (create) => create.create({output}));
helpArgs(command, {});
await import("../src/create.js").then(async (create) => create.create());
break;
}
case "deploy": {
Expand Down Expand Up @@ -184,9 +181,9 @@ function helpArgs<T extends ParseArgsConfig>(command: string | undefined, config
}
if ((result.values as any).help) {
console.log(
`Usage: observable ${command}${
command === undefined || command === "help" ? " <command>" : command === "create" ? " <output-dir>" : ""
}${Object.entries(config.options ?? {})
`Usage: observable ${command}${command === undefined || command === "help" ? " <command>" : ""}${Object.entries(
config.options ?? {}
)
.map(([name, {default: def}]) => ` [--${name}${def === undefined ? "" : `=${def}`}]`)
.join("")}`
);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
]
},
"dependencies": {
"@clack/prompts": "^0.7.0",
"@observablehq/inputs": "^0.10.6",
"@observablehq/runtime": "^5.9.4",
"@rollup/plugin-node-resolve": "^15.2.3",
Expand All @@ -66,7 +67,6 @@
"markdown-it-anchor": "^8.6.7",
"mime": "^3.0.0",
"open": "^9.1.0",
"prompts": "^2.4.2",
"rollup": "^4.6.0",
"rollup-plugin-esbuild": "^6.1.0",
"send": "^0.18.0",
Expand Down
13 changes: 13 additions & 0 deletions src/clack.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type * as clack from "@clack/prompts";

export interface ClackEffects {
text: (typeof clack)["text"];
intro: (typeof clack)["intro"];
select: (typeof clack)["select"];
confirm: (typeof clack)["confirm"];
spinner: (typeof clack)["spinner"];
note: (typeof clack)["note"];
outro: (typeof clack)["outro"];
cancel: (typeof clack)["cancel"];
group<T>(prompts: clack.PromptGroup<T>, options?: clack.PromptGroupOptions<T>): Promise<unknown>;
}
228 changes: 126 additions & 102 deletions src/create.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
import {existsSync} from "node:fs";
import {copyFile, mkdir, readFile, readdir, stat, writeFile} from "node:fs/promises";
import {join, normalize, parse, resolve} from "node:path";
import {exec} from "node:child_process";
import {accessSync, existsSync, readdirSync, statSync} from "node:fs";
import {constants, copyFile, mkdir, readFile, readdir, stat, writeFile} from "node:fs/promises";
import {basename, dirname, join, normalize, resolve} from "node:path";
import {setTimeout as sleep} from "node:timers/promises";
import {fileURLToPath} from "node:url";
import {type PromptObject, default as prompts} from "prompts";
import {promisify} from "node:util";
import * as clack from "@clack/prompts";
import type {ClackEffects} from "./clack.js";
import {cyan, inverse, reset, underline} from "./tty.js";

export interface CreateEffects {
clack: ClackEffects;
sleep: (delay?: number) => Promise<void>;
log(output: string): void;
mkdir(outputPath: string): Promise<void>;
mkdir(outputPath: string, options?: {recursive?: boolean}): Promise<void>;
copyFile(sourcePath: string, outputPath: string): Promise<void>;
writeFile(outputPath: string, contents: string): Promise<void>;
}

const defaultEffects: CreateEffects = {
clack,
sleep,
log(output: string): void {
console.log(output);
},
async mkdir(outputPath: string): Promise<void> {
await mkdir(outputPath);
async mkdir(outputPath: string, options): Promise<void> {
await mkdir(outputPath, options);
},
async copyFile(sourcePath: string, outputPath: string): Promise<void> {
await copyFile(sourcePath, outputPath);
Expand All @@ -26,119 +35,136 @@ const defaultEffects: CreateEffects = {
}
};

export async function create({output = ""}: {output?: string}, effects: CreateEffects = defaultEffects): Promise<void> {
const {dir: projectDir, name: projectNameArg} = parse(output);

if (projectNameArg !== "") {
const result = validateProjectName(projectDir, projectNameArg);
if (result !== true) {
console.error(`Invalid project "${join(projectDir, projectNameArg)}": ${result}`);
process.exit(1);
}
}

const results = await prompts<"projectName" | "projectTitle">([
// TODO Do we want to accept the output path as a command-line argument,
// still? It’s not sufficient to run observable create non-interactively,
// though we could just apply all the defaults in that case, and then expose
// command-line arguments for the other prompts. In any case, our immediate
// priority is supporting the interactive case, not the automated one.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function create(options = {}, effects: CreateEffects = defaultEffects): Promise<void> {
const {clack} = effects;
clack.intro(inverse(" observable create "));
await clack.group(
{
type: "text",
name: "projectName",
message: "Project folder name:",
initial: projectNameArg,
validate: (name) => validateProjectName(projectDir, name)
} satisfies PromptObject<"projectName">,
rootPath: () =>
clack.text({
message: "Where to create your project?",
placeholder: "./hello-framework",
defaultValue: "./hello-framework",
validate: validateRootPath
}),
includeSampleFiles: () =>
clack.select({
message: "Include sample files to help you get started?",
options: [
{value: true, label: "Yes, include sample files", hint: "recommended"},
{value: false, label: "No, create an empty project"}
],
initialValue: true
}),
packageManager: () =>
clack.select({
message: "Install dependencies?",
options: [
{value: "npm", label: "Yes, via npm", hint: "recommended"},
{value: "yarn", label: "Yes, via yarn", hint: "recommended"},
{value: null, label: "No"}
],
initialValue: inferPackageManager()
}),
initializeGit: () =>
clack.confirm({
message: "Initialize git repository?"
}),
installing: async ({results: {rootPath, includeSampleFiles, packageManager, initializeGit}}) => {
const s = clack.spinner();
s.start("Copying template files");
const template = includeSampleFiles ? "default" : "empty";
const templateDir = resolve(fileURLToPath(import.meta.url), "..", "..", "templates", template);
const title = basename(rootPath!);
const runCommand = packageManager === "yarn" ? "yarn" : `${packageManager ?? "npm"} run`;
const installCommand = packageManager === "yarn" ? "yarn" : `${packageManager ?? "npm"} install`;
await effects.sleep(1000);
await recursiveCopyTemplate(
templateDir,
rootPath!,
{
runCommand,
installCommand,
rootPath: rootPath!,
projectTitle: title,
projectTitleString: JSON.stringify(title)
},
effects
);
if (packageManager) {
s.message(`Installing dependencies via ${packageManager}`);
await effects.sleep(1000);
await promisify(exec)(packageManager, {cwd: rootPath});
}
if (initializeGit) {
s.message("Initializing git repository");
await effects.sleep(1000);
await promisify(exec)("git init", {cwd: rootPath});
await promisify(exec)("git add -A", {cwd: rootPath});
}
s.stop("Installed!");
const instructions = [`cd ${rootPath}`, ...(packageManager ? [] : [installCommand]), `${runCommand} dev`];
clack.note(instructions.map((line) => reset(cyan(line))).join("\n"), "Next steps…");
clack.outro(`Problems? ${underline("https://cli.observablehq.com/getting-started")}`);
}
},
{
type: "text",
name: "projectTitle",
message: "Project title (visible on the pages):",
initial: toTitleCase,
validate: validateProjectTitle
} satisfies PromptObject<"projectTitle">
]);

if (results.projectName === undefined || results.projectTitle === undefined) {
console.log("Create process aborted");
process.exit(0);
}

const root = join(projectDir, results.projectName);
const pkgInfo = pkgFromUserAgent(process.env["npm_config_user_agent"]);
const pkgManager = pkgInfo ? pkgInfo.name : "yarn";

const templateDir = resolve(fileURLToPath(import.meta.url), "../../templates/default");

const devDirections =
pkgManager === "yarn" ? ["yarn", "yarn dev"] : [`${pkgManager} install`, `${pkgManager} run dev`];

const context = {
projectDir,
...results,
projectTitleString: JSON.stringify(results.projectTitle),
devInstructions: devDirections.map((l) => `$ ${l}`).join("\n")
};

effects.log(`Setting up project in ${root}...`);
await recursiveCopyTemplate(templateDir, root, context, undefined, effects);

effects.log("All done! To get started, run:\n");
if (root !== process.cwd()) {
effects.log(` cd ${root.includes(" ") ? `"${root}"` : root}`);
}
for (const line of devDirections) {
effects.log(` ${line}`);
}
onCancel: () => {
clack.cancel("create cancelled");
process.exit(0);
}
}
);
}

function validateProjectName(projectDir: string, projectName: string): string | boolean {
if (!existsSync(normalize(projectDir))) {
return "The parent directory of the project does not exist.";
}
if (projectName.length === 0) {
return "Project name must be at least 1 character long.";
}
if (existsSync(join(projectDir, projectName))) {
return "Project already exists.";
}
if (!/^([^0-9\W][\w-]*)$/.test(projectName)) {
return "Project name must contain only alphanumerics, dash or underscore with no leading digits.";
}
return true;
function validateRootPath(rootPath: string): string | void {
if (rootPath === "") return; // accept default value
rootPath = normalize(rootPath);
if (!canWriteRecursive(rootPath)) return "Path is not writable.";
if (!existsSync(rootPath)) return;
if (!statSync(rootPath).isDirectory()) return "File already exists.";
if (readdirSync(rootPath).length !== 0) return "Directory is not empty.";
}

function validateProjectTitle(projectTitle: string): string | boolean {
if (projectTitle.length === 0) {
return "Project title must be at least 1 character long.";
}
// eslint-disable-next-line no-control-regex
if (/[\u0000-\u001F\u007F-\u009F]/.test(projectTitle)) {
return "Project title may not contain control characters.";
function canWriteRecursive(rootPath: string): boolean {
while (true) {
const dir = dirname(rootPath);
try {
accessSync(dir, constants.W_OK);
return true;
} catch {
// try parent
}
if (dir === rootPath) break;
rootPath = dir;
}
return true;
}

function toTitleCase(str: string): string {
return str
.split(/[\s_-]+/)
.map(([c, ...rest]) => c.toUpperCase() + rest.join(""))
.join(" ");
return false;
}

async function recursiveCopyTemplate(
inputRoot: string,
outputRoot: string,
context: Record<string, string>,
stepPath: string = ".",
effects: CreateEffects
effects: CreateEffects,
stepPath: string = "."
) {
const templatePath = join(inputRoot, stepPath);
const templateStat = await stat(templatePath);
let outputPath = join(outputRoot, stepPath);
if (templateStat.isDirectory()) {
try {
await effects.mkdir(outputPath); // TODO recursive?
await effects.mkdir(outputPath, {recursive: true});
} catch {
// that's ok
}
for (const entry of await readdir(templatePath)) {
await recursiveCopyTemplate(inputRoot, outputRoot, context, join(stepPath, entry), effects);
await recursiveCopyTemplate(inputRoot, outputRoot, context, effects, join(stepPath, entry));
}
} else {
if (templatePath.endsWith(".DS_Store")) return;
Expand All @@ -157,14 +183,12 @@ async function recursiveCopyTemplate(
}
}

function pkgFromUserAgent(userAgent: string | undefined): null | {
name: string;
version: string | undefined;
} {
function inferPackageManager(): string | null {
const userAgent = process.env["npm_config_user_agent"];
if (!userAgent) return null;
const pkgSpec = userAgent.split(" ")[0]!; // userAgent is non-empty, so this is always defined
if (!pkgSpec) return null;
const [name, version] = pkgSpec.split("/");
if (!name || !version) return null;
return {name, version};
return name;
}
2 changes: 2 additions & 0 deletions src/tty.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type {Logger} from "./logger.js";

export const reset = color(0, 0);
export const bold = color(1, 22);
export const faint = color(2, 22);
export const italic = color(3, 23);
export const underline = color(4, 24);
export const inverse = color(7, 27);
export const red = color(31, 39);
export const green = color(32, 39);
export const yellow = color(33, 39);
Expand Down
Loading
Loading