Skip to content
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
41 changes: 28 additions & 13 deletions .github/workflows/scaffold-smoke.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
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"

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:
Expand All @@ -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 }}"
74 changes: 63 additions & 11 deletions scripts/scaffold-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -30,19 +32,69 @@ 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"
exit 1
}
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"
10 changes: 10 additions & 0 deletions src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <typescript|python|go> or set "language" in .apsorc.'
)
);
}
const implementedLanguages = getImplementedLanguages();
const { selectedLanguage } = await inquirer.prompt<{ selectedLanguage: TargetLanguage }>([
{
Expand Down
39 changes: 39 additions & 0 deletions src/lib/utils/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}

/**
Expand Down
73 changes: 73 additions & 0 deletions test/lib/utils/stamp-apsorc-language.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading