Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
acff036
Add design spec for as-lan SDK support
tomusdrw Apr 28, 2026
97809b8
Pin as-lan SDK spec to published 0.0.4 image
tomusdrw May 7, 2026
380ff60
Add implementation plan for as-lan framework support
tomusdrw May 7, 2026
fbc40e9
Add aslan-0.0.4 entry to SDK_CONFIGS
tomusdrw May 7, 2026
a3f07b3
Add SDK_ALIASES map and resolveSdkId helper
tomusdrw May 7, 2026
4eda534
Fix resolveSdkId to ignore prototype properties
tomusdrw May 7, 2026
05128a3
Widen ServiceConfig.sdk type to accept alias keys
tomusdrw May 7, 2026
4376a7d
Replace runtime type tautologies with compile-time assertions
tomusdrw May 7, 2026
c693614
Accept SDK aliases in build config validator
tomusdrw May 7, 2026
f277f0f
Tighten rejection-test assertions to match validator error message
tomusdrw May 7, 2026
1852cc4
Resolve SDK aliases in build command
tomusdrw May 7, 2026
fdac82d
Add negative test for unknown SDK id throw
tomusdrw May 7, 2026
3b3e932
Resolve SDK aliases in test command
tomusdrw May 7, 2026
4565852
Extract resolveSdk helper to deduplicate SDK lookup
tomusdrw May 7, 2026
94f872d
Register aslan template in create-command
tomusdrw May 7, 2026
9ed06bc
Document as-lan SDK in service examples
tomusdrw May 7, 2026
260b97e
Lock in validator/resolver consistency invariant + doc nuance
tomusdrw May 8, 2026
56959c3
List ajanta, jamc3, and aslan templates in getting-started
tomusdrw May 8, 2026
3a770b5
Add test-command unit tests covering docker invocation
tomusdrw May 8, 2026
1b0187a
Document SDK rebuild step for local test runs
tomusdrw May 8, 2026
0b06504
Address PR review: untrack internal plan docs, await rejects assertions
tomusdrw May 8, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,6 @@ bun.lockb

# e2e test temporary directory
.test

# Internal planning artifacts (specs/plans for in-progress work)
docs/superpowers/
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ bun test bin/cli/src/commands/create-command.test.ts # Run specific test file
bun test --watch # Run tests in watch mode
```

> **Heads-up for SDK changes.** The `@fluffylabs/jammin-sdk` package is published from `packages/jammin-sdk/dist/`, and CLI tests import it via the package name. After editing files under `packages/jammin-sdk/`, run `bun run build` before `bun test` so test imports see the new symbols. CI runs `bun run build` before `bun test` automatically; the manual step is only needed locally.

## Code Style & Conventions

### Linting & Formatting (Biome)
Expand Down
39 changes: 37 additions & 2 deletions bin/cli/src/commands/build-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ describe("build-command", () => {
expect(dockerCommand).toContain(`${resolve("/test/project", "./jade")}:/app`);
});

test("should generate correct Docker command for as-lan alias", async () => {
const service: ServiceConfig = {
name: "as-lan-service",
path: "./services/example",
sdk: "as-lan",
};

await callDockerBuild(service, "/test/project");

expect(mockSpawn).toHaveBeenCalledTimes(1);
const spawnCall = mockSpawn.mock.calls[0];
if (!spawnCall) {
throw new Error("spawnCall is undefined");
}
const dockerCommand = spawnCall[0][2] as string;

expect(dockerCommand).toContain(SDK_CONFIGS["aslan-0.0.4"].image);
expect(dockerCommand).toContain(SDK_CONFIGS["aslan-0.0.4"].build);
expect(dockerCommand).toContain(`${resolve("/test/project", "./services/example")}:/app`);
});

test("should generate correct Docker command for custom SDK config", async () => {
const customSdk: SdkConfig = {
image: "custom-image:latest",
Expand Down Expand Up @@ -132,8 +153,22 @@ describe("build-command", () => {
sdk: "jambrains-1cfc41c",
};

expect(callDockerBuild(service, "/test/project")).rejects.toThrow();
expect(callDockerBuild(service, "/test/project")).rejects.toThrow("Build failed for service 'failing-service'");
await expect(callDockerBuild(service, "/test/project")).rejects.toThrow(
"Build failed for service 'failing-service'",
);
});

test("should throw with descriptive message when SDK id is unknown", async () => {
const service: ServiceConfig = {
name: "broken-service",
path: "./broken",
// biome-ignore lint/suspicious/noExplicitAny: simulating a stale config that bypassed validation
sdk: "definitely-not-a-real-sdk" as any,
};

await expect(callDockerBuild(service, "/test/project")).rejects.toThrow(
"Unknown SDK id: 'definitely-not-a-real-sdk'",
);
});

test("should return build output on success", async () => {
Expand Down
4 changes: 2 additions & 2 deletions bin/cli/src/commands/build-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
getJamFiles,
getServiceConfigs,
loadServices,
SDK_CONFIGS,
resolveSdk,
} from "@fluffylabs/jammin-sdk";
import { Command } from "commander";

Expand All @@ -25,7 +25,7 @@ export class DockerError extends Error {
}

export async function callDockerBuild(service: ServiceConfig, projectRoot: string): Promise<string> {
const sdk = typeof service.sdk === "string" ? SDK_CONFIGS[service.sdk] : service.sdk;
const sdk = resolveSdk(service.sdk);
const servicePath = resolve(projectRoot, service.path);

const dockerArgs = ["run", "--rm", "-v", `${servicePath}:/app`, sdk.image, ...sdk.build.split(" ")];
Expand Down
3 changes: 2 additions & 1 deletion bin/cli/src/commands/create-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import * as p from "@clack/prompts";
import { fetchRepo, updatePackageJson } from "@fluffylabs/jammin-sdk";
import { Command, InvalidArgumentError } from "commander";

type Template = "jam-sdk" | "jade" | "jambrains" | "ajanta" | "jamc3" | "undecided";
type Template = "jam-sdk" | "jade" | "jambrains" | "ajanta" | "jamc3" | "aslan" | "undecided";

const TARGETS: Record<Template, string> = {
"jam-sdk": "jammin-create/jammin-create-jam-sdk",
jade: "jammin-create/jammin-create-jade",
jambrains: "jammin-create/jammin-create-jambrains",
ajanta: "jammin-create/jammin-create-ajanta",
jamc3: "jammin-create/jammin-create-jamc3",
aslan: "jammin-create/jammin-create-aslan",
undecided: "jammin-create/jammin-create-undecided",
};

Expand Down
262 changes: 262 additions & 0 deletions bin/cli/src/commands/test-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { resolve } from "node:path";
import { SDK_CONFIGS, type SdkConfig, type ServiceConfig } from "@fluffylabs/jammin-sdk";
import { testService } from "./test-command";

describe("test-command", () => {
describe("testService - Docker command generation", () => {
let originalSpawn: typeof Bun.spawn;
let mockSpawn: ReturnType<typeof mock>;

beforeEach(() => {
originalSpawn = Bun.spawn;
mockSpawn = mock(() => {
return {
stdout: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("test output"));
controller.close();
},
}),
stderr: new ReadableStream({
start(controller) {
controller.close();
},
}),
exited: Promise.resolve(0),
};
});
// biome-ignore lint/suspicious/noExplicitAny: Need to mock Bun.spawn for testing
(Bun as any).spawn = mockSpawn;
});

afterEach(() => {
Bun.spawn = originalSpawn;
});

test("should generate correct Docker command for predefined SDK (jambrains)", async () => {
const service: ServiceConfig = {
name: "test-service",
path: "./services/test",
sdk: "jambrains-1cfc41c",
};

await testService(service, "/test/project");

expect(mockSpawn).toHaveBeenCalledTimes(1);
const spawnCall = mockSpawn.mock.calls[0];
if (!spawnCall) {
throw new Error("spawnCall is undefined");
}
expect(spawnCall[0]).toEqual(["sh", "-c", expect.stringContaining("docker")]);

const dockerCommand = spawnCall[0][2] as string;
expect(dockerCommand).toContain("docker run --rm -v");
expect(dockerCommand).toContain(`${resolve("/test/project", "./services/test")}:/app`);
expect(dockerCommand).toContain(SDK_CONFIGS["jambrains-1cfc41c"].image);
expect(dockerCommand).toContain(SDK_CONFIGS["jambrains-1cfc41c"].test);
});

test("should generate correct Docker command for predefined SDK (jade)", async () => {
const service: ServiceConfig = {
name: "jade-service",
path: "./jade",
sdk: "jade-0.0.15-pre.1",
};

await testService(service, "/test/project");

expect(mockSpawn).toHaveBeenCalledTimes(1);
const spawnCall = mockSpawn.mock.calls[0];
if (!spawnCall) {
throw new Error("spawnCall is undefined");
}
const dockerCommand = spawnCall[0][2] as string;

expect(dockerCommand).toContain(SDK_CONFIGS["jade-0.0.15-pre.1"].image);
expect(dockerCommand).toContain(SDK_CONFIGS["jade-0.0.15-pre.1"].test);
expect(dockerCommand).toContain(`${resolve("/test/project", "./jade")}:/app`);
});

test("should generate correct Docker command for as-lan alias", async () => {
const service: ServiceConfig = {
name: "as-lan-service",
path: "./services/example",
sdk: "as-lan",
};

await testService(service, "/test/project");

expect(mockSpawn).toHaveBeenCalledTimes(1);
const spawnCall = mockSpawn.mock.calls[0];
if (!spawnCall) {
throw new Error("spawnCall is undefined");
}
const dockerCommand = spawnCall[0][2] as string;

expect(dockerCommand).toContain(SDK_CONFIGS["aslan-0.0.4"].image);
expect(dockerCommand).toContain(SDK_CONFIGS["aslan-0.0.4"].test);
expect(dockerCommand).toContain(`${resolve("/test/project", "./services/example")}:/app`);
});

test("should generate correct Docker command for custom SDK config", async () => {
const customSdk: SdkConfig = {
image: "custom-image:latest",
build: "custom build command with args",
test: "custom test command",
};

const service: ServiceConfig = {
name: "custom-service",
path: "./custom",
sdk: customSdk,
};

await testService(service, "/test/project");

expect(mockSpawn).toHaveBeenCalledTimes(1);
const spawnCall = mockSpawn.mock.calls[0];
if (!spawnCall) {
throw new Error("spawnCall is undefined");
}
const dockerCommand = spawnCall[0][2] as string;

expect(dockerCommand).toContain("custom-image:latest");
expect(dockerCommand).toContain("custom test command");
expect(dockerCommand).toContain(`${resolve("/test/project", "./custom")}:/app`);
});

test("should handle test failure with non-zero exit code", async () => {
const mockFailedSpawn = mock(() => {
return {
stdout: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("test error output"));
controller.close();
},
}),
stderr: new ReadableStream({
start(controller) {
controller.close();
},
}),
exited: Promise.resolve(1),
};
});

// biome-ignore lint/suspicious/noExplicitAny: Need to mock Bun.spawn for testing
(Bun as any).spawn = mockFailedSpawn;

const service: ServiceConfig = {
name: "failing-service",
path: "./fail",
sdk: "jambrains-1cfc41c",
};

await expect(testService(service, "/test/project")).rejects.toThrow("Tests failed for service 'failing-service'");
});

test("should throw with descriptive message when SDK id is unknown", async () => {
const service: ServiceConfig = {
name: "broken-service",
path: "./broken",
// biome-ignore lint/suspicious/noExplicitAny: simulating a stale config that bypassed validation
sdk: "definitely-not-a-real-sdk" as any,
};

await expect(testService(service, "/test/project")).rejects.toThrow(
"Unknown SDK id: 'definitely-not-a-real-sdk'",
);
});

test("should return test output on success", async () => {
const expectedOutput = "test successful output";
const mockSuccessSpawn = mock(() => {
return {
stdout: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(expectedOutput));
controller.close();
},
}),
stderr: new ReadableStream({
start(controller) {
controller.close();
},
}),
exited: Promise.resolve(0),
};
});

// biome-ignore lint/suspicious/noExplicitAny: Need to mock Bun.spawn for testing
(Bun as any).spawn = mockSuccessSpawn;

const service: ServiceConfig = {
name: "success-service",
path: "./success",
sdk: "jambrains-1cfc41c",
};

const output = await testService(service, "/test/project");
expect(output).toBe(expectedOutput);
});
});

describe("testService - service path resolution", () => {
let originalSpawn: typeof Bun.spawn;

beforeEach(() => {
originalSpawn = Bun.spawn;
});

afterEach(() => {
Bun.spawn = originalSpawn;
});

test("should resolve relative service paths correctly", async () => {
const mockSpawn = mock(() => {
return {
stdout: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("test output"));
controller.close();
},
}),
stderr: new ReadableStream({
start(controller) {
controller.close();
},
}),
exited: Promise.resolve(0),
};
});

// biome-ignore lint/suspicious/noExplicitAny: Need to mock Bun.spawn for testing
(Bun as any).spawn = mockSpawn;

const service: ServiceConfig = {
name: "test-service",
path: "./services/test",
sdk: "jambrains-1cfc41c",
};

const projectRoot = "/absolute/project/root";
await testService(service, projectRoot);

expect(mockSpawn).toHaveBeenCalledTimes(1);
expect(mockSpawn.mock.calls.length).toBeGreaterThan(0);
const spawnCall = mockSpawn.mock.calls[0];
if (!spawnCall || spawnCall.length === 0) {
throw new Error("spawnCall is undefined or empty");
}
const spawnArgs = (spawnCall as unknown[])[0] as string[];
if (!spawnArgs || spawnArgs.length < 3) {
throw new Error("spawnArgs is invalid");
}
const dockerCommand = spawnArgs[2] as string;
const expectedPath = resolve(projectRoot, service.path);

expect(dockerCommand).toContain(`${expectedPath}:/app`);
});
});
});
4 changes: 2 additions & 2 deletions bin/cli/src/commands/test-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdir } from "node:fs/promises";
import { join, relative, resolve } from "node:path";
import * as p from "@clack/prompts";
import type { ServiceConfig } from "@fluffylabs/jammin-sdk";
import { getServiceConfigs, SDK_CONFIGS } from "@fluffylabs/jammin-sdk";
import { getServiceConfigs, resolveSdk } from "@fluffylabs/jammin-sdk";
import { Command } from "commander";

export class DockerError extends Error {
Expand All @@ -18,7 +18,7 @@ export class DockerError extends Error {
* Test a single service using Docker
*/
export async function testService(service: ServiceConfig, projectRoot: string): Promise<string> {
const sdk = typeof service.sdk === "string" ? SDK_CONFIGS[service.sdk] : service.sdk;
const sdk = resolveSdk(service.sdk);
const servicePath = resolve(projectRoot, service.path);

const dockerArgs = ["run", "--rm", "-v", `${servicePath}:/app`, sdk.image, ...sdk.test.split(" ")];
Expand Down
3 changes: 3 additions & 0 deletions docs/src/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ The interactive wizard will ask you:
- `jam-sdk` - JAM SDK template for building JAM services
- `jade` - JADE SDK template
- `jambrains` - JamBrains SDK template
- `ajanta` - Ajanta (Python) SDK template
- `jamc3` - JAMC3 (C3) SDK template
- `aslan` - as-lan (AssemblyScript) SDK template
- `undecided` - Starter template for exploring options with all of the above

### Command-line mode
Expand Down
Loading
Loading