diff --git a/.github/workflows/scaffold-smoke.yml b/.github/workflows/scaffold-smoke.yml index c4ec9c6..cc142e5 100644 --- a/.github/workflows/scaffold-smoke.yml +++ b/.github/workflows/scaffold-smoke.yml @@ -1,20 +1,15 @@ name: scaffold smoke test -# Scaffolds a fresh project with the CLI and compiles it (apsoai/cli#101). +# Scaffolds a fresh project with the CLI, generates code from a seeded +# entity, and compiles the result — one leg per language (apsoai/cli#101). # -# Intentionally NOT a pull_request gate: the TypeScript template currently -# references unpublished @apso/crud packages (file:../apso-crud), so a fresh -# scaffold does not yet install/compile. Wiring this to pull_request would red -# every PR until the template target is resolved (see cli#99 / publishing -# @apso/crud). Run it on demand (or weekly) to verify once that lands, then -# flip the trigger to pull_request to make it a real gate. +# Intentionally NOT a pull_request gate yet: each leg clones its service +# template repo and installs dependencies from the network, so template-side +# breakage would red unrelated CLI PRs. Weekly + on-demand keeps it a +# low-noise heartbeat; consider flipping to pull_request once the legs have +# a stable green streak. on: workflow_dispatch: - inputs: - language: - description: "Template language to scaffold" - required: false - default: "typescript" schedule: # Mondays 06:00 UTC — a low-noise heartbeat, does not block PRs. - cron: "0 6 * * 1" @@ -22,6 +17,14 @@ on: jobs: scaffold-and-compile: runs-on: ubuntu-latest + strategy: + # One leg per language: this workflow is the only regression net for + # the Python and Go generators, so a red leg in one language must not + # cancel the others. + fail-fast: false + matrix: + language: [typescript, python, go] + name: scaffold-and-compile (${{ matrix.language }}) steps: - uses: actions/checkout@v4 with: @@ -34,5 +37,17 @@ jobs: registry-url: "https://npm.pkg.github.com" scope: "@mavric" + - name: Setup Go + if: matrix.language == 'go' + uses: actions/setup-go@v5 + with: + go-version: "stable" + + - name: Setup Python + if: matrix.language == 'python' + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Scaffold and compile - run: bash scripts/scaffold-smoke.sh "${{ github.event.inputs.language || 'typescript' }}" + run: bash scripts/scaffold-smoke.sh "${{ matrix.language }}" diff --git a/scripts/scaffold-smoke.sh b/scripts/scaffold-smoke.sh index d7d901e..7398710 100755 --- a/scripts/scaffold-smoke.sh +++ b/scripts/scaffold-smoke.sh @@ -5,12 +5,14 @@ # Drives the CLI exactly as a new user would, then proves the scaffolded # project actually compiles: # 1. build the CLI from this checkout -# 2. `apso init` a fresh TypeScript project (offline, --skip-platform) -# 3. `apso generate` from the scaffolded .apsorc (if present) -# 4. install the project's deps and typecheck it +# 2. `apso init` a fresh project (offline, --skip-platform) +# 3. `apso generate` from the scaffolded .apsorc (if present) — no +# --language flag on purpose: this also regression-tests that a fresh +# scaffold resolves its language headlessly instead of prompting +# 4. install the project's deps and compile/typecheck it (per language) # # Exit non-zero on the first failure so CI surfaces a broken scaffold. -# Run locally: bash scripts/scaffold-smoke.sh +# Run locally: bash scripts/scaffold-smoke.sh [typescript|python|go] set -euo pipefail LANGUAGE="${1:-typescript}" @@ -30,6 +32,25 @@ cd "$WORKDIR" cd smoke-app if [ -f .apsorc ]; then + # Templates ship with "entities": [] and every generator rejects an empty + # entity list, so seed one small entity. This also turns the run into a real + # codegen check: the generated files must compile below. + echo "==> Seeding a smoke entity into .apsorc" + node -e ' + const fs = require("fs"); + const rc = JSON.parse(fs.readFileSync(".apsorc", "utf8")); + rc.entities = [{ + name: "SmokeWidget", + created_at: true, + updated_at: true, + fields: [ + { name: "label", type: "text" }, + { name: "count", type: "integer", nullable: true }, + ], + }]; + fs.writeFileSync(".apsorc", JSON.stringify(rc, null, 2) + "\n"); + ' + echo "==> Generating code from .apsorc" "$CLI_ROOT/bin/run" generate || { echo "FAIL: apso generate errored on a fresh scaffold" @@ -37,12 +58,43 @@ if [ -f .apsorc ]; then } fi -echo "==> Installing scaffolded project dependencies" -npm install - -echo "==> Typechecking the scaffolded project" -if [ "$LANGUAGE" = "typescript" ]; then - npx tsc --noEmit -fi +case "$LANGUAGE" in + typescript) + echo "==> Installing scaffolded project dependencies" + npm install + echo "==> Typechecking the scaffolded project" + # --rootDir .: the v2.0.0 template's tsconfig includes test/**/* while + # setting rootDir to src, which trips TS6059 under a bare tsc run. rootDir + # only shapes emit layout and this is --noEmit, so widening it is safe. + npx tsc --noEmit --rootDir . + ;; + go) + echo "==> Resolving scaffolded project dependencies" + go mod tidy + # cmd/main.go imports the swag-generated app/docs package; the template + # README's setup runs swag init before the first build, so mirror that. + echo "==> Generating Swagger docs (app/docs package)" + go install github.com/swaggo/swag/cmd/swag@latest + "$(go env GOPATH)/bin/swag" init -g cmd/main.go -o docs + echo "==> Compiling the scaffolded project" + go build ./... + ;; + python) + # The template requires Python >= 3.11; override with PYTHON=python3.12 + # if the default python3 is older. + PYTHON_BIN="${PYTHON:-python3}" + echo "==> Installing scaffolded project dependencies" + "$PYTHON_BIN" -m venv .venv + # shellcheck disable=SC1091 + . .venv/bin/activate + pip install --quiet -e . + echo "==> Byte-compiling the scaffolded project" + python -m compileall -q app + ;; + *) + echo "FAIL: unknown language '$LANGUAGE' (expected typescript, python, or go)" + exit 1 + ;; +esac echo "PASS: scaffolded $LANGUAGE project installs and compiles" diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 116160a..2b902f2 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -9,6 +9,7 @@ import { } from "../lib"; import { TargetLanguage, GeneratorConfig } from "../lib/types"; import BaseCommand from "../lib/base-command"; +import { isInteractive, missingFlag } from "../lib/utils/interactive"; import { performance } from "perf_hooks"; import { createFile } from "../lib/utils/file-system"; import { installCoAuthorHook } from "../lib/utils/git-hooks"; @@ -53,6 +54,15 @@ export default class Generate extends BaseCommand { language = configLanguage; console.log(`[apso] Using language from .apsorc: ${language}`); } else { + // Headless (CI / no TTY / APSO_NONINTERACTIVE): never block on a prompt. + // Fail fast and name the flag so the caller can retry deterministically. + if (!isInteractive()) { + this.error( + missingFlag( + 'Pass --language or set "language" in .apsorc.' + ) + ); + } const implementedLanguages = getImplementedLanguages(); const { selectedLanguage } = await inquirer.prompt<{ selectedLanguage: TargetLanguage }>([ { diff --git a/src/lib/utils/template.ts b/src/lib/utils/template.ts index 043dbf5..3cc4f96 100644 --- a/src/lib/utils/template.ts +++ b/src/lib/utils/template.ts @@ -66,6 +66,45 @@ export function cloneTemplate( shell.rm("-rf", path.join(projectPath, ".git")); ensureEnvFile(projectPath, log); + stampApsorcLanguage(projectPath, language, log); +} + +/** + * Stamp the chosen language into the scaffolded `.apsorc`. + * + * `apso generate` resolves its target language as flag > .apsorc > prompt. + * The TypeScript template (pinned at v2.0.0) ships an `.apsorc` without a + * `language` field, so a bare `apso generate` right after `apso init` fell + * through to the interactive prompt — which errors in non-TTY contexts + * (this is what kept the weekly scaffold-smoke workflow red). Recording the + * language `init` was given makes the scaffold self-describing. + * + * Best-effort: leaves an existing `language` value alone and skips files it + * cannot parse as JSON. + */ +export function stampApsorcLanguage( + projectPath: string, + language: TargetLanguage, + log: (msg: string) => void +): void { + const apsorcPath = path.join(projectPath, ".apsorc"); + if (!fs.existsSync(apsorcPath)) return; + + try { + const raw = fs.readFileSync(apsorcPath, "utf-8"); + const config = JSON.parse(raw) as { language?: string }; + if (config.language) return; + config.language = language; + const indent = raw.match(/^([\t ]+)"/m)?.[1] ?? " "; + fs.writeFileSync( + apsorcPath, + `${JSON.stringify(config, null, indent)}\n` + ); + log(`Recorded "language": "${language}" in .apsorc`); + } catch { + // .apsorc may use a format we don't fully parse (e.g. comments) — + // leave it untouched rather than risk mangling the user's schema file. + } } /** diff --git a/test/lib/utils/stamp-apsorc-language.test.ts b/test/lib/utils/stamp-apsorc-language.test.ts new file mode 100644 index 0000000..5711258 --- /dev/null +++ b/test/lib/utils/stamp-apsorc-language.test.ts @@ -0,0 +1,73 @@ +import { expect } from "@jest/globals"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { stampApsorcLanguage } from "../../../src/lib/utils/template"; + +const noop = (): void => { /* no-op logger for tests */ }; + +function tmpProject(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "apso-stamp-test-")); +} + +describe("stampApsorcLanguage", () => { + test("writes the language into an .apsorc that lacks one (scaffold-smoke red)", () => { + const dir = tmpProject(); + fs.writeFileSync( + path.join(dir, ".apsorc"), + JSON.stringify({ version: 1, rootFolder: "src", entities: [] }, null, 4) + ); + + stampApsorcLanguage(dir, "typescript", noop); + + // eslint-disable-next-line unicorn/prefer-json-parse-buffer -- TS types JSON.parse as string-only + const config = JSON.parse(fs.readFileSync(path.join(dir, ".apsorc"), "utf-8")); + expect(config.language).toBe("typescript"); + // Existing fields survive the rewrite. + expect(config.version).toBe(1); + expect(config.rootFolder).toBe("src"); + expect(config.entities).toEqual([]); + }); + + test("leaves an existing language value alone", () => { + const dir = tmpProject(); + const original = JSON.stringify({ version: 2, language: "go", entities: [] }); + fs.writeFileSync(path.join(dir, ".apsorc"), original); + + stampApsorcLanguage(dir, "typescript", noop); + + expect(fs.readFileSync(path.join(dir, ".apsorc"), "utf-8")).toBe(original); + }); + + test("is a no-op when the project has no .apsorc", () => { + const dir = tmpProject(); + + stampApsorcLanguage(dir, "typescript", noop); + + expect(fs.existsSync(path.join(dir, ".apsorc"))).toBe(false); + }); + + test("leaves an unparseable .apsorc untouched", () => { + const dir = tmpProject(); + const original = '// jsonc comment\n{ "version": 1 }\n'; + fs.writeFileSync(path.join(dir, ".apsorc"), original); + + stampApsorcLanguage(dir, "python", noop); + + expect(fs.readFileSync(path.join(dir, ".apsorc"), "utf-8")).toBe(original); + }); + + test("preserves the file's existing indentation", () => { + const dir = tmpProject(); + fs.writeFileSync( + path.join(dir, ".apsorc"), + JSON.stringify({ version: 1, entities: [] }, null, 4) + ); + + stampApsorcLanguage(dir, "go", noop); + + const raw = fs.readFileSync(path.join(dir, ".apsorc"), "utf-8"); + expect(raw).toContain(' "version"'); + expect(JSON.parse(raw).language).toBe("go"); + }); +});