Skip to content

Commit

Permalink
feat: interactive CLI with Clack (#72)
Browse files Browse the repository at this point in the history
* feat: interactive CLI with Clack

* fix up input-from-script

* without-undefined-properties

* fix: remove Math.random()

* findPositionalFrom.test.ts

* Reorganization

* Undid bin/index.js changes

* Add logError.ts

* fix: bin/index.js with console

* Basically the rest of it: positionals, suggestions, no more optionsAugment...

* Most of the way through adding tests

* Use import-local-or-npx

* pnpm add import-local-or-npx

* Fix linting

* Don't crash on unknown zod schemas

* override octokit system for runBlock and runPreset tests

* missing Octokit import

* more mock systems: produceBase, producePreset

* one more produceBase system

* eek .only
  • Loading branch information
JoshuaKGoldberg authored Dec 20, 2024
1 parent a4bcc47 commit 78bc44b
Show file tree
Hide file tree
Showing 95 changed files with 4,022 additions and 796 deletions.
2 changes: 1 addition & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
"packages/site/src/content",
"pnpm-lock.yaml"
],
"words": ["astrojs", "Rajlich", "tsconfigs", "tseslint"]
"words": ["astrojs", "outro", "Rajlich", "tsconfigs", "tseslint"]
}
44 changes: 4 additions & 40 deletions packages/create-testers/src/testPreset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const emptyCreation = {
files: {},
requests: [],
scripts: [],
suggestions: [],
};

const base = createBase({
Expand All @@ -18,10 +19,6 @@ const base = createBase({
});

describe("testPreset", () => {
// TODO: It would be nice to also test the case of no options,
// as testBlock.test.ts does with a @ts-expect-error.
// However, the Object spreads in producePreset wipe Proxy `get`s... 🤷

describe("options", () => {
const blockUsingOptions = base.createBlock({
produce({ options }) {
Expand All @@ -34,6 +31,7 @@ describe("testPreset", () => {
});

const presetUsingOptions = base.createPreset({
about: { name: "Test" },
blocks: [blockUsingOptions],
});

Expand All @@ -43,42 +41,8 @@ describe("testPreset", () => {
});

expect(actual).toEqual({
creation: {
...emptyCreation,
files: { "value.txt": "abc" },
},
options: { value: "abc" },
});
});

it("passes options to the block when provided via optionsAugment", async () => {
const actual = await testPreset(presetUsingOptions, {
optionsAugment: () => ({ value: "abc" }),
});

expect(actual).toEqual({
creation: {
...emptyCreation,
files: { "value.txt": "abc" },
},
options: { value: "abc" },
});
});

it("passes options to the block when provided via options and optionsAugment", async () => {
const actual = await testPreset(presetUsingOptions, {
options: { value: "abc" },
optionsAugment: (options) => ({
value: [options.value, "def"].join("-"),
}),
});

expect(actual).toEqual({
creation: {
...emptyCreation,
files: { "value.txt": "abc-def" },
},
options: { value: "abc-def" },
...emptyCreation,
files: { "value.txt": "abc" },
});
});
});
Expand Down
37 changes: 3 additions & 34 deletions packages/create-testers/src/testPreset.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,16 @@
import {
AnyShape,
FullPresetProductionSettings,
InferredObject,
Preset,
producePreset,
Production,
PromiseOrSync,
SystemContext,
} from "create";

import { createMockSystems } from "./createMockSystems.js";

export interface TestAugmentingPresetProductionSettings<
OptionsShape extends AnyShape,
> extends TestProductionSettingsBase {
options?: Partial<InferredObject<OptionsShape>>;
optionsAugment: (
options: Partial<InferredObject<OptionsShape>>,
) => PromiseOrSync<InferredObject<OptionsShape>>;
}

export interface TestFullPresetProductionSettings<OptionsShape extends AnyShape>
export interface TestPresetProductionSettings<OptionsShape extends AnyShape>
extends TestProductionSettingsBase {
options: InferredObject<OptionsShape>;
optionsAugment?: (
options: InferredObject<OptionsShape>,
) => Promise<Partial<InferredObject<OptionsShape>>>;
}

export interface TestProductionSettingsBase {
Expand All @@ -34,25 +19,9 @@ export interface TestProductionSettingsBase {

export async function testPreset<OptionsShape extends AnyShape>(
preset: Preset<OptionsShape>,
settings: TestAugmentingPresetProductionSettings<OptionsShape>,
): Promise<Production<InferredObject<OptionsShape>>>;
export async function testPreset<OptionsShape extends AnyShape>(
preset: Preset<OptionsShape>,
// TODO: When removing this, optionsAugment's options param is implicitly any.
// Is that a TS bug? Bug in typescript-eslint? To be investigated.
// eslint-disable-next-line @typescript-eslint/unified-signatures
settings: TestFullPresetProductionSettings<OptionsShape>,
): Promise<Production<InferredObject<OptionsShape>>>;
export async function testPreset<OptionsShape extends AnyShape>(
preset: Preset<OptionsShape>,
settings:
| TestAugmentingPresetProductionSettings<OptionsShape>
| TestFullPresetProductionSettings<OptionsShape>,
settings: TestPresetProductionSettings<OptionsShape>,
) {
const { system } = createMockSystems(settings.system);

return await producePreset(preset, {
...settings,
...system,
} as FullPresetProductionSettings<OptionsShape>);
return await producePreset(preset, { ...settings, ...system });
}
2 changes: 1 addition & 1 deletion packages/create/bin/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env node
import { runCli } from "../lib/cli/runCli.js";

process.exitCode = await runCli(...process.argv.slice(2));
process.exitCode = await runCli(process.argv.slice(2), console);
7 changes: 6 additions & 1 deletion packages/create/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,16 @@
"build": "tsc"
},
"dependencies": {
"@clack/prompts": "^0.8.2",
"cached-factory": "^0.1.0",
"chalk": "^5.4.0",
"execa": "^9.5.2",
"get-github-auth-token": "^0.1.0",
"hash-object": "^5.0.1",
"import-local-or-npx": "^0.1.0",
"octokit": "^4.0.2",
"octokit-from-auth": "^0.3.0",
"prettier": "3.4.2",
"without-undefined-properties": "^0.1.1",
"zod": "^3.24.1"
},
"engines": {
Expand Down
58 changes: 58 additions & 0 deletions packages/create/src/cli/findConfigFile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";

import { findConfigFile } from "./findConfigFile.js";

const mockReaddir = vi.fn();

vi.mock("node:fs/promises", () => ({
get readdir() {
return mockReaddir;
},
}));

describe("findConfigFile", () => {
it("returns false when readdir rejects", async () => {
mockReaddir.mockRejectedValueOnce(new Error("Oh no!"));

const actual = await findConfigFile(".");

expect(actual).toBe(undefined);
});

it("returns a create.config.js when it exists", async () => {
mockReaddir.mockResolvedValueOnce(["a", "create.config.js", "b"]);

const actual = await findConfigFile(".");

expect(actual).toBe("create.config.js");
});

it("returns a create.config.ts when it exists", async () => {
mockReaddir.mockResolvedValueOnce(["a", "create.config.ts", "b"]);

const actual = await findConfigFile(".");

expect(actual).toBe("create.config.ts");
});

it("returns a create.config.ts when it is found after a create.config.js", async () => {
mockReaddir.mockResolvedValueOnce([
"a",
"create.config.js",
"create.config.ts",
"b",
]);

const actual = await findConfigFile(".");

expect(actual).toBe("create.config.ts");
});

it("returns undefined when no create.config.* exists", async () => {
mockReaddir.mockResolvedValueOnce(["a", "b"]);

const actual = await findConfigFile(".");

expect(actual).toBe(undefined);
});
});
26 changes: 26 additions & 0 deletions packages/create/src/cli/findConfigFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as fs from "node:fs/promises";

export async function findConfigFile(directory: string) {
try {
const children = await fs.readdir(directory);
let found: string | undefined;

for (const child of children) {
if (child === "create.config.ts") {
return child;
}

if (isConfigFileName(child)) {
found = child;
}
}

return found;
} catch {
return undefined;
}
}

function isConfigFileName(fileName: string) {
return /create\.config\.\w+/.test(fileName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";

import { tryImportAndInstallIfNecessary } from "./tryImportAndInstallIfNecessary.js";

const mockSpinner = {
start: vi.fn(),
stop: vi.fn(),
};

vi.mock("@clack/prompts", () => ({
spinner: () => mockSpinner,
}));

const mockImportLocalOrNpx = vi.fn();

vi.mock("import-local-or-npx", () => ({
get importLocalOrNpx() {
return mockImportLocalOrNpx;
},
}));

const errorLocal = new Error("Error: local");
const errorNpx = new Error("Error: npx");

describe("tryImportAndInstallIfNecessary", () => {
it("returns the local error when importLocalOrNpx resolves with a failure for a local path", async () => {
mockImportLocalOrNpx.mockResolvedValueOnce({
kind: "failure",
local: errorLocal,
npx: errorNpx,
});

const actual = await tryImportAndInstallIfNecessary("../create-my-app");

expect(actual).toBe(errorLocal);
expect(mockSpinner.start.mock.calls).toEqual([
["Retrieving ../create-my-app"],
]);
expect(mockSpinner.stop.mock.calls).toEqual([
["Could not retrieve ../create-my-app"],
]);
});

it("returns the npx error when importLocalOrNpx resolves with a failure for a package name", async () => {
mockImportLocalOrNpx.mockResolvedValueOnce({
kind: "failure",
local: errorLocal,
npx: errorNpx,
});

const actual = await tryImportAndInstallIfNecessary("create-my-app");

expect(actual).toBe(errorNpx);
expect(mockSpinner.start.mock.calls).toEqual([
["Retrieving create-my-app"],
]);
expect(mockSpinner.stop.mock.calls).toEqual([
["Could not retrieve create-my-app"],
]);
});

it("returns the package when importLocalOrNpx resolves a package", async () => {
const resolved = { happy: true };

mockImportLocalOrNpx.mockResolvedValueOnce({
kind: "local",
resolved,
});

const actual = await tryImportAndInstallIfNecessary("create-my-app");

expect(actual).toBe(resolved);
expect(mockSpinner.start.mock.calls).toEqual([
["Retrieving create-my-app"],
]);
expect(mockSpinner.stop.mock.calls).toEqual([["Retrieved create-my-app"]]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as prompts from "@clack/prompts";
import { importLocalOrNpx } from "import-local-or-npx";

import { isLocalPath } from "../utils.js";

export async function tryImportAndInstallIfNecessary(
from: string,
): Promise<Error | object> {
const spinner = prompts.spinner();
spinner.start(`Retrieving ${from}`);

const imported = await importLocalOrNpx(from);

if (imported.kind === "failure") {
spinner.stop(`Could not retrieve ${from}`);
return isLocalPath(from) ? imported.local : imported.npx;
}

spinner.stop(`Retrieved ${from}`);

return imported.resolved;
}
Loading

0 comments on commit 78bc44b

Please sign in to comment.