genaiscript
Version:
A CLI for GenAIScript, a generative AI scripting framework.
534 lines (489 loc) • 19.7 kB
JavaScript
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import { resolve } from "node:path";
import { snakeCase } from "es-toolkit";
import { CORE_VERSION, GENAI_ANY_REGEX, GENAI_SRC, GitHubClient, MODEL_PROVIDERS, MODEL_PROVIDER_AZURE_OPENAI, MODEL_PROVIDER_GITHUB, MODEL_PROVIDER_OPENAI, MODEL_PROVIDER_AZURE_AI_INFERENCE, YAMLStringify, YAMLTryParse, createScript as coreCreateScript, dedent, deleteEmptyValues, deleteUndefinedValues, genaiscriptDebug, isCI, logInfo, logVerbose, nodeTryReadPackage, templateIdFromFileName, titleize, tryReadText, tryStat, writeText, resolveRuntimeHost, } from "@genaiscript/core";
import { buildProject } from "@genaiscript/core";
import { shellConfirm, shellSelect } from "@genaiscript/runtime";
const dbg = genaiscriptDebug("cli:action");
const github = GitHubClient.default();
/**
* Generates GitHub Action files for a given script, including action.yml, Dockerfile, package.json, README.md, and .gitignore, using script metadata and provided options.
*
* If scriptId is not provided, prompts the user for the script name and initializes a new script. If scriptId is given, attempts to load the script from the project.
*
* Parameters:
* scriptId: The identifier or filename of the script for which action files will be generated. If falsy, user will be prompted to enter a name and a new script will be created.
* options: Configuration object with the following optional properties:
* force: If true, overwrite existing files without prompting.
* out: Output directory for generated files. Defaults to action/<script.id> under the genaiscript workspace.
* ffmpeg: If true, install ffmpeg in the Docker image.
* python: If true, install python3 and py3-pip in the Docker image.
* playwright: If true, use Playwright Docker image and install Playwright dependencies.
* packageLock: If true, generate a package-lock.json file using npm ci or npm install.
* image: Base Docker image to use. Defaults to Playwright image if playwright flag is set, otherwise node:lts-alpine.
* apks: Additional Alpine packages to install in the Docker image.
* provider: Name of the GenAI provider to use in the start command.
*
* Throws:
* Error if the script cannot be found when scriptId is provided.
*
* Side Effects:
* Writes or overwrites files in the output directory.
* Executes npm or node commands to generate lock files if packageLock is set.
*/
export async function actionConfigure(scriptId, options) {
options = options || {};
const { owner, repo } = (await github.info()) || {};
if (!owner || !repo)
throw new Error("GitHub repository information not found.");
const { force, out = resolve("."), provider, pullRequestComment, pullRequestDescription, pullRequestReviews, interactive, } = options;
scriptId = scriptId || "action";
dbg(`owner: %s`, owner);
dbg(`repo: %s`, repo);
dbg(`script: %s`, scriptId);
const writeFile = async (name, content) => {
const filePath = resolve(out, name);
if (!force && (await tryStat(filePath))) {
logInfo(`skipping ${filePath} (file already exists), use --force to overwrite`);
}
else {
logVerbose(`writing ${filePath}`);
await writeText(filePath, content);
}
};
if (!isCI && interactive) {
options.event =
options.event ||
(await shellSelect("What event will trigger the action?", [
"push",
"pull_request",
"issue_comment",
"issue",
]));
options.python =
options.python === undefined
? await shellConfirm("Will you use Python?", {
default: false,
})
: options.python;
if (options.event === "pull_request") {
options.pullRequestDescription =
options.pullRequestDescription === undefined
? await shellConfirm("Will you publish the output as a pull request description?", {
default: false,
})
: options.pullRequestDescription;
options.pullRequestComment =
options.pullRequestComment === undefined
? await shellConfirm("Will you publish the output as a pull request comment?", {
default: false,
})
: options.pullRequestComment;
options.pullRequestReviews =
options.pullRequestReviews === undefined
? await shellConfirm("Will you publish diagnostics as a pull request review comments?", {
default: false,
})
: options.pullRequestReviews;
}
options.playwright =
options.playwright === undefined
? await shellConfirm("Will you use Playwright? (browse(...)", {
default: false,
})
: options.playwright;
options.ffmpeg =
options.ffmpeg === undefined
? await shellConfirm("Will you use ffmpeg?", {
default: false,
})
: options.ffmpeg;
}
const event = options.event ??
(pullRequestComment || pullRequestDescription || pullRequestReviews ? "pull_request" : "push");
const issue = event === "issue" || event === "issue_comment";
const pullRequest = event === "pull_request";
logVerbose(`event: ${event}`);
const prj = await buildProject(); // Build the project to get script templates
let script = prj.scripts.find((t) => t.id === scriptId ||
(t.filename && GENAI_ANY_REGEX.test(scriptId) && resolve(t.filename) === resolve(scriptId)));
if (!script) {
script = coreCreateScript(scriptId);
script.id = scriptId;
script.filename = resolve(out, GENAI_SRC, templateIdFromFileName(scriptId) + ".genai.mts");
// Write the prompt script to the determined path
await writeFile(script.filename, script.jsSource);
}
const accept = script.accept;
const ffmpeg = options.ffmpeg || /ffmpeg$/.test(script.jsSource);
const playwright = options.playwright || /host\.browser/.test(script.jsSource);
const python = options.python;
const image = options.image ||
(playwright ? "mcr.microsoft.com/playwright:v1.52.0-noble" : "node:lts-alpine");
const alpine = /alpine$/.test(image);
logVerbose(`script: ${script.filename}`);
logVerbose(`docker image: ${image}`);
logVerbose(`ffmpeg: ${ffmpeg}`);
logVerbose(`python: ${python}`);
logVerbose(`playwright: ${playwright}`);
const { inputSchema, branding } = script;
const scriptSchema = inputSchema?.properties.script || {
type: "object",
properties: {},
required: [],
};
const providers = MODEL_PROVIDERS.filter(({ id }) => [
MODEL_PROVIDER_GITHUB,
MODEL_PROVIDER_OPENAI,
MODEL_PROVIDER_AZURE_OPENAI,
MODEL_PROVIDER_AZURE_AI_INFERENCE,
].includes(id)).filter(({ env }) => env);
const inputs = deleteUndefinedValues({
...Object.fromEntries(Object.entries(scriptSchema.properties).map(([key, value]) => {
return [
snakeCase(key),
{
description: value.description || "",
required: scriptSchema.required?.includes(key) || false,
default: value.default ?? undefined,
},
];
})),
files: accept === "none"
? undefined
: {
description: `Files to process, separated by semi columns (;). ${accept || ""}`,
required: false,
},
debug: {
description: "Enable [debug logging](https://microsoft.github.io/genaiscript/reference/scripts/logging/).",
required: false,
},
model_alias: {
description: "A YAML-like list of model aliases and model id: `translation: github:openai/gpt-4o`",
required: false,
},
github_issue: issue || pullRequest
? {
description: `GitHub ${issue ? "issue" : "pull request"} number to use when [generating comments](https://microsoft.github.io/genaiscript/reference/scripts/github/)`,
required: false,
}
: undefined,
});
for (const pro of providers) {
for (const [key, value] of Object.entries(pro.env)) {
inputs[key.toLowerCase()] = deleteUndefinedValues({
description: value.description || pro.url || `Configuration for ${pro.id} provider`,
required: false,
});
}
}
let outputs = deleteUndefinedValues({
text: {
description: "The generated text output.",
},
data: script.responseSchema
? {
description: "The generated data output, parsed and stringified as JSON.",
}
: undefined,
});
if (!Object.keys(outputs).length)
outputs = undefined;
const pkg = await nodeTryReadPackage();
const apks = [
"git",
"github-cli",
python ? "python3" : undefined,
python ? "py3-pip" : undefined,
ffmpeg ? "ffmpeg" : undefined,
...(options.apks || []),
].filter(Boolean);
const actionYmlFilename = resolve(out, "action.yml");
const action = YAMLTryParse(await tryReadText(actionYmlFilename));
if (action && !force) {
logVerbose(`updating action.yml`);
action.description = script.description || pkg?.description;
action.inputs = inputs;
action.outputs = outputs;
action.branding = branding;
await writeText(actionYmlFilename, YAMLStringify(action));
}
else {
await writeFile("action.yml", YAMLStringify(deleteEmptyValues({
name: titleize(repo),
author: pkg?.author,
description: script.title || pkg?.description,
inputs,
outputs,
branding,
runs: {
using: "docker",
image: "Dockerfile",
},
})));
}
await writeFile("Dockerfile", dedent `# For additional guidance on containerized actions, see https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
FROM ${image}
# Install packages
${alpine ? `RUN apk add --no-cache ${apks.join(" ")}` : `RUN apt-get update && apt-get install -y ${apks.join(" ")}`}
# Set working directory
WORKDIR /genaiscript/action
# Copy source code
COPY . .
# Install dependencies
RUN npm ci
${playwright
? dedent `# Install playwright dependencies
RUN npx --yes playwright install --with-deps chromium
`
: ""}
# GitHub Action forces the WORKDIR to GITHUB_WORKSPACE
ENTRYPOINT ["npm", "--prefix", "/genaiscript/action", "start"]
`);
await writeFile("README.md", dedent `# ${script.title || titleize(repo)}
${script.description || ""}
> This action uses [GitHub Models](https://github.com/models) for LLM inference.
## Inputs
|name|description|required|default|
|----|-----------|--------|-------|
${Object.entries(inputs || {})
.map(([key, value]) => `| \`${key}\` | ${value.description || ""} | ${value.required ? "true" : "false"} | ${value.default || ""} |`)
.join("\n")}
${outputs
? `## Outputs
|name|description|
|----|-----------|
${Object.entries(outputs)
.map(([key, value]) => `| \`${key}\` | ${value.description || ""} |`)
.join("\n")}
`
: ""}
## Usage
Add the following to your step in your workflow file:
\`\`\`yaml
uses: ${owner}/${repo}@main
with:
${Object.entries(inputs || {})
.filter(([key, value]) => value.required || key === "github_token")
.map(([key]) => ` ${key}: \${{ ${key === "github_token" ? "secrets.GITHUB_TOKEN" : "..."} }}`)
.join("\n")}
\`\`\`
## Example
Save this file in your \`.github/workflows/\` directory as \`${script.id}.yml\`:
\`\`\`yaml
name: ${titleize(repo)}
on:
${event}:
permissions:
contents: read
${!issue ? "# " : ""}issues: write
${event !== "pull_request" ? "# " : ""}pull-requests: write
models: read
concurrency:
group: \${{ github.workflow }}-\${{ github.ref }}
cancel-in-progress: true
jobs:
${snakeCase(repo)}:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: .genaiscript/cache/**
key: genaiscript-\${{ github.run_id }}
restore-keys: |
genaiscript-
- uses: ${owner}/${repo}@v0 # update to the major version you want to use
with:
${Object.entries(inputs || {})
.filter(([key, value]) => value.required || key === "github_token")
.map(([key]) => ` ${key}: \${{ ${key === "github_token" ? "secrets.GITHUB_TOKEN" : "..."} }}`)
.join("\n")}
\`\`\`
## Development
This action was automatically generated by [GenAIScript](https://microsoft.github.io/genaiscript/reference/github-actions) from the script metadata.
We recommend updating the script metadata instead of editing the action files directly.
- the action inputs are inferred from the script parameters
- the action outputs are inferred from the script output schema
- the action description is the script description
- the readme description is the script description
- the action branding is the script branding
To **regenerate** the action files (\`action.yml\`), run:
\`\`\`bash
npm run configure
\`\`\`
To lint script files, run:
\`\`\`bash
npm run lint
\`\`\`
To typecheck the scripts, run:
\`\`\`bash
npm run typecheck
\`\`\`
To build the Docker image locally, run:
\`\`\`bash
npm run docker:build
\`\`\`
To run the action locally in Docker (build it first), use:
\`\`\`bash
npm run docker:start
\`\`\`
## Upgrade
The GenAIScript version is pinned in the \`package.json\` file. To upgrade it, run:
\`\`\`bash
npm run upgrade
\`\`\`
## Release
To release a new version of this action, run the release script on a clean working directory.
\`\`\`bash
npm run release
\`\`\`
`);
await writeFile(".devcontainer/devcontainer.json", JSON.stringify({
name: "GenAIScript GitHub Action Dev Container",
build: {
dockerfile: "Dockerfile",
},
features: {},
customizations: {
vscode: {
settings: {
"terminal.integrated.defaultProfile.linux": "ash",
"terminal.integrated.profiles.linux": {
ash: {
path: "/bin/ash",
args: ["-l"],
},
},
},
extensions: [
"GitHub.vscode-github-actions",
"esbenp.prettier-vscode",
"GitHub.copilot-chat",
"genaiscript.genaiscript-vscode",
],
},
},
postCreateCommand: 'git config --global --add safe.directory "$(pwd)" && npm ci',
}, null, 2));
await writeFile(".devcontainer/Dockerfile", dedent `# Keep this Dockerfile in sync with the main Dockerfile
FROM ${image}
# Install packages
${alpine ? `RUN apk add --no-cache ${apks.join(" ")}` : `RUN apt-get update && apt-get install -y ${apks.join(" ")}`}
`);
await writeFile(".nvmrc", "lts/*");
await writeFile("release.sh", dedent `#!/bin/bash
set -e # exit immediately if a command exits with a non-zero status
# make sure there's no other changes
git pull
# genaiscript build
npm run typecheck
# Step 0: ensure we're in sync
if [ "$(git status --porcelain)" ]; then
echo "❌ Pending changes detected. Commit or stash them first."
exit 1
fi
# typecheck test
npm run typecheck
# Step 1: Bump patch version using npm
NEW_VERSION=$(npm version patch -m "chore: bump version to %s")
echo "version: $NEW_VERSION"
# Step 2: Push commit and tag
git push origin HEAD --tags
# Step 3: Create GitHub release
gh release create "$NEW_VERSION" --title "$NEW_VERSION" --notes "Patch release $NEW_VERSION"
# Step 4: update major tag if any
MAJOR=$(echo "$NEW_VERSION" | cut -d. -f1)
echo "major: $MAJOR"
git tag -f $MAJOR $NEW_VERSION
git push origin $MAJOR --force
echo "✅ GitHub release $NEW_VERSION created successfully."
`);
await writeFile(".github/workflows/ci.yml", `name: Continuous Integration
on:
pull_request:
branches:
- main
push:
branches:
- main
permissions:
contents: read
models: read
concurrency:
group: \${{ github.workflow }}-\${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: npm
- run: npm ci
- run: npm test
test-action:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./
with:
github_token: \${{ secrets.GITHUB_TOKEN }}
`);
if (!pkg || force) {
const args = [
`genaiscript`,
`run`,
scriptId,
provider ? `--provider` : undefined,
provider ? `--provider ${provider}` : undefined,
pullRequestComment ? `--pull-request-comment` : undefined,
typeof pullRequestComment === "string" ? pullRequestComment : undefined,
pullRequestDescription ? `--pull-request-description` : undefined,
typeof pullRequestDescription === "string" ? pullRequestDescription : undefined,
pullRequestReviews ? `--pull-request-reviews` : undefined,
].filter(Boolean);
await writeFile("package.json", JSON.stringify(deleteUndefinedValues({
private: true,
version: "0.0.0",
author: pkg?.author,
license: pkg?.license,
description: script.description,
dependencies: deleteUndefinedValues({
...(pkg?.dependencies || {}),
genaiscript: CORE_VERSION,
...(playwright ? { "@genaiscript/plugin-playwright": CORE_VERSION } : {}),
}),
scripts: {
upgrade: "npx -y npm-check-updates -u && npm install && npm run fix",
"docker:build": `docker build -t ${owner}-${repo} .`,
"docker:start": `docker run -e GITHUB_TOKEN ${owner}-${repo}`,
lint: `npx --yes prettier --write genaisrc/`,
fix: "genaiscript scripts fix",
typecheck: `genaiscript scripts compile`,
configure: [`genaiscript configure action`, scriptId].filter(Boolean).join(" "),
test: "echo 'No tests defined.'",
dev: args.join(" "),
start: [
...args,
"--github-workspace",
"--no-run-trace",
"--no-output-trace",
"--out-output",
"$GITHUB_STEP_SUMMARY",
].join(" "),
release: "sh release.sh",
},
}), null, 2));
}
// upgrade dependencies
const runtimeHost = resolveRuntimeHost();
await runtimeHost.exec(undefined, "node", ["run", "upgrade"], {
cwd: out,
});
}
//# sourceMappingURL=action.js.map