-
Notifications
You must be signed in to change notification settings - Fork 234
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
feat(cli): Add shortest init
command
#297
Changes from all commits
d8c99dd
f2e3ae6
69f7830
5b24671
b82171e
2298e29
c294db3
7b69fa0
157afca
c312cd6
45f5189
2033b37
1c9fa94
14f6d7f
e505bab
b4aafd9
1b851fc
e692aeb
2abd772
99e5ae2
8ba6343
a1349ea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,8 @@ | ||
#!/usr/bin/env node | ||
|
||
import pc from "picocolors"; | ||
import { getConfig } from ".."; | ||
import { GitHubTool } from "../browser/integrations/github"; | ||
import { CONFIG_FILENAME, ENV_LOCAL_FILENAME } from "../constants"; | ||
import { TestRunner } from "../core/runner"; | ||
|
||
process.removeAllListeners("warning"); | ||
|
@@ -42,7 +42,7 @@ ${pc.bold("Options:")} | |
--no-cache Disable caching (storing browser actions between tests) | ||
|
||
${pc.bold("Authentication:")} | ||
--secret=<key> GitHub TOTP secret key (or use .env.local) | ||
--secret=<key> GitHub TOTP secret key (or use ${ENV_LOCAL_FILENAME}) | ||
|
||
${pc.bold("Examples:")} | ||
${pc.dim("# Run all tests")} | ||
|
@@ -58,7 +58,7 @@ ${pc.bold("Examples:")} | |
shortest --github-code --secret=<OTP_SECRET> | ||
|
||
${pc.bold("Environment Setup:")} | ||
Required variables in .env.local: | ||
Required variables in ${ENV_LOCAL_FILENAME}: | ||
- ANTHROPIC_API_KEY Required for AI test execution | ||
- GITHUB_TOTP_SECRET Required for GitHub authentication | ||
- GITHUB_USERNAME GitHub login credentials | ||
|
@@ -111,6 +111,11 @@ function isValidArg(arg: string): boolean { | |
async function main() { | ||
const args = process.argv.slice(2); | ||
|
||
if (args[0] === "init") { | ||
await require("../commands/init").default(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extracted all the logic to its own module. |
||
process.exit(0); | ||
} | ||
|
||
if (args.includes("--help") || args.includes("-h")) { | ||
showHelp(); | ||
process.exit(0); | ||
|
@@ -157,7 +162,7 @@ async function main() { | |
console.error(pc.dim(error.message)); | ||
console.error( | ||
pc.dim( | ||
"\nMake sure you have a valid shortest.config.ts with all required fields:", | ||
`\nMake sure you have a valid ${CONFIG_FILENAME} with all required fields:`, | ||
), | ||
); | ||
console.error(pc.dim(" - headless: boolean")); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import { execSync } from "child_process"; | ||
import { readFile, writeFile } from "node:fs/promises"; | ||
import { join } from "path"; | ||
import { fileURLToPath } from "url"; | ||
import { detect, resolveCommand } from "package-manager-detector"; | ||
import pc from "picocolors"; | ||
import { CONFIG_FILENAME, ENV_LOCAL_FILENAME } from "../../constants"; | ||
import { addToEnv } from "../../utils/add-to-env"; | ||
import { addToGitignore } from "../../utils/add-to-gitignore"; | ||
|
||
export default async function main() { | ||
console.log(pc.blue("Setting up Shortest...")); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
try { | ||
const packageJson = await getPackageJson(); | ||
if ( | ||
packageJson?.dependencies?.["@antiwork/shortest"] || | ||
packageJson?.devDependencies?.["@antiwork/shortest"] | ||
) { | ||
console.log(pc.green("✔ Package already installed")); | ||
return; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Important to exit early if the package is already installed, otherwise there would be some considerable effort to ensure the install doesn't overwrite any existing data. |
||
} else { | ||
console.log("Installing @antiwork/shortest..."); | ||
const installCmd = await getInstallCmd(); | ||
execSync(installCmd, { stdio: "inherit" }); | ||
console.log(pc.green("✔ Dependencies installed")); | ||
} | ||
|
||
const configPath = join(process.cwd(), CONFIG_FILENAME); | ||
const exampleConfigPath = join( | ||
fileURLToPath(new URL("../../src", import.meta.url)), | ||
`${CONFIG_FILENAME}.example`, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Simplified the logic to use an example file. |
||
); | ||
|
||
const exampleConfig = await readFile(exampleConfigPath, "utf8"); | ||
await writeFile(configPath, exampleConfig, "utf8"); | ||
console.log(pc.green(`✔ ${CONFIG_FILENAME} created`)); | ||
|
||
const envResult = await addToEnv(process.cwd(), { | ||
ANTHROPIC_API_KEY: { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think it's worth adding Mailosaur at this time, although other ENVs can be easily added in the future. |
||
value: "your_value_here", | ||
comment: "Shortest variables", | ||
}, | ||
}); | ||
if (envResult.error) { | ||
console.error( | ||
pc.red(`Failed to update ${ENV_LOCAL_FILENAME}`), | ||
envResult.error, | ||
); | ||
} else if (envResult.added.length > 0) { | ||
const added = envResult.added.join(", "); | ||
const skipped = envResult.skipped.join(", "); | ||
const detailsString = [ | ||
added ? `${added} added` : "", | ||
skipped ? `${skipped} skipped` : "", | ||
] | ||
.filter(Boolean) | ||
.join(", "); | ||
console.log( | ||
pc.green( | ||
`✔ ${ENV_LOCAL_FILENAME} ${envResult.wasCreated ? "created" : "updated"} (${detailsString})`, | ||
), | ||
); | ||
} | ||
|
||
const resultGitignore = await addToGitignore(process.cwd(), [ | ||
".env*.local", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not worth the effort trying to group with other |
||
".shortest/", | ||
]); | ||
if (resultGitignore.error) { | ||
console.error( | ||
pc.red("Failed to update .gitignore"), | ||
resultGitignore.error, | ||
); | ||
} else { | ||
console.log( | ||
pc.green( | ||
`✔ .gitignore ${resultGitignore.wasCreated ? "created" : "updated"}`, | ||
), | ||
); | ||
} | ||
|
||
console.log(pc.green("\nInitialization complete! Next steps:")); | ||
console.log(`1. Update ${ENV_LOCAL_FILENAME} with your values`); | ||
console.log("2. Create your first test file: example.test.ts"); | ||
console.log("3. Run tests with: npx/pnpm test example.test.ts"); | ||
} catch (error) { | ||
console.error(pc.red("Initialization failed:"), error); | ||
process.exit(1); | ||
} | ||
} | ||
|
||
export const getPackageJson = async () => { | ||
try { | ||
return JSON.parse( | ||
await readFile(join(process.cwd(), "package.json"), "utf8"), | ||
); | ||
} catch {} | ||
}; | ||
|
||
export const getInstallCmd = async () => { | ||
const packageManager = (await detect()) || { agent: "npm", version: "" }; | ||
const packageJson = await getPackageJson(); | ||
if (packageJson?.packageManager) { | ||
const [name] = packageJson.packageManager.split("@"); | ||
if (["pnpm", "yarn", "bun"].includes(name)) { | ||
packageManager.agent = name; | ||
} | ||
} | ||
|
||
const command = resolveCommand(packageManager.agent, "install", [ | ||
"@antiwork/shortest", | ||
"--save-dev", | ||
]); | ||
|
||
if (!command) { | ||
throw new Error(`Unsupported package manager: ${packageManager.agent}`); | ||
} | ||
|
||
const cmdString = `${command.command} ${command.args.join(" ")}`; | ||
console.log(pc.dim(cmdString)); | ||
|
||
return cmdString; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export const CONFIG_FILENAME = "shortest.config.ts"; | ||
export const ENV_LOCAL_FILENAME = ".env.local"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import type { ShortestConfig } from "@antiwork/shortest"; | ||
|
||
export default { | ||
headless: false, | ||
baseUrl: "http://localhost:3000", | ||
testPattern: "**/*.test.ts", | ||
anthropicKey: process.env.ANTHROPIC_API_KEY, | ||
} satisfies ShortestConfig; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
import { readFile, writeFile } from "node:fs/promises"; | ||
import os from "os"; | ||
import { join } from "path"; | ||
import { ENV_LOCAL_FILENAME } from "../constants"; | ||
|
||
type EnvResult = { | ||
added: string[]; | ||
skipped: string[]; | ||
wasCreated: boolean; | ||
error?: Error; | ||
}; | ||
|
||
export async function addToEnv( | ||
path: string, | ||
entries: Record<string, { value: string; comment?: string }>, | ||
): Promise<EnvResult> { | ||
const result: EnvResult = { | ||
added: [], | ||
skipped: [], | ||
wasCreated: false, | ||
}; | ||
|
||
try { | ||
const envPath = join(path, ENV_LOCAL_FILENAME); | ||
let envContent = await readFile(envPath, "utf8").catch(() => null); | ||
result.wasCreated = envContent === null; | ||
envContent = envContent ?? ""; | ||
const EOL = envContent.includes("\r\n") ? "\r\n" : os.EOL; | ||
|
||
const existingEntries = new Map( | ||
envContent | ||
.split(EOL) | ||
.filter((line) => line.trim() && !line.startsWith("#")) | ||
.map((line) => { | ||
const [key] = line.split("="); | ||
return [key.trim(), true]; | ||
}), | ||
); | ||
|
||
let content = envContent; | ||
for (const [key, { value, comment }] of Object.entries(entries)) { | ||
if (existingEntries.has(key)) { | ||
result.skipped.push(key); | ||
continue; | ||
} | ||
|
||
const needsEol = content.length > 0 && !content.endsWith(EOL); | ||
if (comment) { | ||
content += `${needsEol ? EOL : ""}# ${comment}${EOL}`; | ||
} | ||
content += `${needsEol && !comment ? EOL : ""}${key}=${value}${EOL}`; | ||
result.added.push(key); | ||
} | ||
|
||
if (result.added.length > 0) { | ||
await writeFile(envPath, content); | ||
} | ||
} catch (error) { | ||
result.error = error as Error; | ||
} | ||
|
||
return result; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Manual instructions can be removed. The description below outlines the steps, in case someone would like to do it manually.