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
8 changes: 8 additions & 0 deletions .changeset/library-single-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"stack-effect": minor
"@stack-effect/author": minor
---

Catalog templates can select content based on the modules attached to a target with `module` and `noModule` conditions.

Publishable libraries now use one `package.json` for workspace use and npm publication. Build before importing the library in the workspace, use `npm run dev` to watch changes, and run `npm run pack` or `npm publish` from the library directory.
3 changes: 3 additions & 0 deletions .github/workflows/check-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,6 @@ jobs:

- name: Verify Clean Install
run: bun run --cwd packages/author verify:package

- name: Verify Packed Library
run: bun run --cwd apps/cli verify:library
43 changes: 37 additions & 6 deletions apps/cli/e2e/matrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const defaultTargetNames = new Map([
["client-foldkit", "app"],
["server-mcp", ""],
["cli", "app"],
["package", "domain"],
["package", "library"],
]);

const catalogModules = await Effect.runPromise(
Expand Down Expand Up @@ -108,10 +108,13 @@ const requiredProviderSelections = (
}
if (dependency._tag !== "required-capability") continue;

const provider = getCapabilityProviders({
const providers = getCapabilityProviders({
capability: dependency.capability,
target: dependency.target,
})[0];
});
const provider =
providers.find((candidate) => moduleIds.includes(candidate.id)) ??
providers[0];
if (!provider) continue;

const target = identityToTarget(dependency.target);
Expand Down Expand Up @@ -164,6 +167,32 @@ function groupModulesByTarget(
return groups;
}

const providedCapabilities = (moduleId: string) =>
catalogModulesById.get(ModuleId.make(moduleId))?.provides ?? [];

/** Maximal bundles choose one provider for each capability. */
const maximalBundles = (moduleIds: ReadonlyArray<string>) =>
[...new Set(moduleIds.flatMap(providedCapabilities))].reduce<
ReadonlyArray<ReadonlyArray<string>>
>(
(bundles, capability) =>
bundles.flatMap((bundle) => {
const providers = bundle.filter((id) =>
providedCapabilities(id).includes(capability),
);
return providers.length <= 1
? [bundle]
: providers.map((provider) =>
bundle.filter(
(id) =>
id === provider ||
!providedCapabilities(id).includes(capability),
),
);
}),
[moduleIds],
);

const buildMatrix = Effect.gen(function* () {
const catalog = yield* CatalogService;
const entries: Array<MatrixEntry> = [];
Expand All @@ -181,11 +210,13 @@ const buildMatrix = Effect.gen(function* () {
});
}

if (moduleIds.length > 1) {
for (const bundle of maximalBundles(moduleIds).filter(
(bundle) => bundle.length > 1,
)) {
entries.push({
target,
modules: moduleIds,
label: `${target} + [all: ${moduleIds.join(", ")}]`,
modules: bundle,
label: `${target} + [all: ${bundle.join(", ")}]`,
});
}
}
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"type-check": "tsc --noEmit",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"verify:create-path": "bun scripts/verify-create-path.ts"
"verify:create-path": "bun scripts/verify-create-path.ts",
"verify:library": "bun scripts/verify-library.ts"
},
"devDependencies": {
"@effect/platform-bun": "4.0.0",
Expand Down
278 changes: 278 additions & 0 deletions apps/cli/scripts/verify-library.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
// Qualify the local catalog's library tarball in consumers outside this repository.
import { NodeRuntime, NodeServices } from "@effect/platform-node";
import { OfficialCatalogLayer } from "@repo/catalog-official/service";
import { Apply } from "@repo/domain/Apply";
import { ModuleId, TargetIdentity, TargetKind } from "@repo/domain/Catalog";
import { StackConfig } from "@repo/domain/Scaffold";
import { ApplyService, BlueprintService, PlanService } from "@repo/scaffold";
import {
Console,
Data,
Effect,
FileSystem,
Layer,
Path,
Schema,
Stream,
} from "effect";
import { ChildProcess } from "effect/process";
import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";

const json = Schema.encodeSync(Schema.fromJsonString(Schema.Json));

class VerificationFailure extends Data.TaggedError("VerificationFailure")<{
readonly message: string;
}> {}

const requireThat = (condition: boolean, message: string) =>
condition ? Effect.void : Effect.fail(new VerificationFailure({ message }));

const run = (cwd: string, command: string, args: ReadonlyArray<string>) =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner;
const handle = yield* spawner.spawn(
ChildProcess.make(command, args, { cwd, stdout: "pipe", stderr: "pipe" }),
);
const [stdout, stderr, exitCode] = yield* Effect.all(
[
Stream.mkString(Stream.decodeText(handle.stdout)),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
);
yield* requireThat(
exitCode === 0,
`${command} ${args.join(" ")} failed\n${stdout}\n${stderr}`,
);
return stdout;
}).pipe(Effect.scoped);

const Packed = Schema.fromJsonString(
Schema.Array(
Schema.Struct({
filename: Schema.String,
files: Schema.Array(Schema.Struct({ path: Schema.String })),
}),
),
);

const verify = (runtime: "bun" | "node") =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const blueprintService = yield* BlueprintService;
const planService = yield* PlanService;
const applyService = yield* ApplyService;
const root = yield* fs.makeTempDirectoryScoped({
prefix: `stack-library-${runtime}-`,
});
const project = path.join(root, "project");
yield* fs.makeDirectory(project);
const config = new StackConfig({
name: "library-proof",
runtime:
runtime === "bun"
? { _tag: "bun" }
: { _tag: "node", packageManager: "npm" },
typescript: "7",
});
const blueprint = yield* blueprintService.resolve(
{
targets: [
{
identity: new TargetIdentity({
kind: TargetKind.make("workspace"),
name: "root",
}),
modules: [{ id: ModuleId.make("workspace-typescript-7") }],
},
{
identity: new TargetIdentity({
kind: TargetKind.make("package"),
name: "sdk",
}),
modules: [{ id: ModuleId.make("package-library") }],
},
],
},
config,
);
const plan = yield* planService.build({
blueprint,
config,
repoRoot: project,
});
yield* requireThat(
plan.conflicts.length === 0,
"Fresh library must not require Apply decisions",
);
const applied = yield* applyService.apply({
apply: new Apply({ plan, decisions: [] }),
repoRoot: project,
});
yield* requireThat(applied.failed.length === 0, "Library Apply failed");
yield* run(project, runtime === "bun" ? "bun" : "npm", [
"install",
"--ignore-scripts",
]);

const library = path.join(project, "packages/sdk");
yield* requireThat(
(yield* run(library, "npm", ["run", "example"])).includes(
"Hello, Effect!",
),
"Generated library example must run through its public export",
);
yield* fs.writeFileString(
path.join(library, "src/index.ts"),
'export { answer } from "./Answer.js";\n',
);
yield* fs.writeFileString(
path.join(library, "src/Answer.ts"),
'import { Effect } from "effect";\nexport const answer: Effect.Effect<number> = Effect.succeed(42);\n',
);
yield* fs.writeFileString(
path.join(library, "src/Obsolete.ts"),
"export const obsolete = true;\n",
);
yield* run(library, "npm", ["run", "build"]);
yield* requireThat(
!(yield* fs.exists(path.join(library, "package.publish.json"))) &&
!(yield* fs.exists(path.join(library, "scripts/prepare-package.mjs"))),
"Library must use a single manifest without a preparation script",
);
yield* fs.writeFileString(
path.join(library, "verify-workspace.mjs"),
'import { answer } from "@repo/sdk";\nimport { Effect } from "effect";\nif (Effect.runSync(answer) !== 42) throw new Error("Workspace compiled export failed");\n',
);
yield* run(library, "node", ["verify-workspace.mjs"]);

yield* requireThat(
yield* fs.exists(path.join(library, "dist/Obsolete.js")),
"Build must emit the source file before the cleanup check",
);
yield* fs.remove(path.join(library, "src/Obsolete.ts"));
const packed = yield* Schema.decodeEffect(Packed)(
yield* run(library, "npm", [
"--silent",
"run",
"pack",
"--",
"--json",
"--pack-destination",
root,
]),
);
const archive = packed[0];
if (archive === undefined)
return yield* new VerificationFailure({
message: "npm pack did not return an archive",
});
const files = archive.files.map((file) => file.path);
yield* requireThat(
[
"dist/index.js",
"dist/index.d.ts",
"dist/Answer.js",
"dist/Answer.d.ts",
].every((file) => files.includes(file)),
"Tarball is missing public JavaScript or declarations",
);
yield* requireThat(
files.every(
(file) =>
["package.json", "README.md", "LICENSE"].includes(file) ||
(file.startsWith("dist/") &&
(file.endsWith(".js") || file.endsWith(".d.ts")) &&
!file.includes("Obsolete") &&
!file.endsWith(".map")),
),
"Tarball contains stale or development files",
);

const consumer = path.join(root, "consumer");
yield* fs.makeDirectory(consumer);
yield* fs.writeFileString(
path.join(consumer, "package.json"),
json({
name: "library-consumer",
private: true,
type: "module",
}),
);
yield* run(consumer, "npm", [
"install",
"--ignore-scripts",
"--no-audit",
"--no-fund",
path.join(root, archive.filename),
"typescript@6.0.3",
]);
yield* fs.writeFileString(
path.join(consumer, "index.mjs"),
`import { answer } from "@repo/sdk";
import { Effect } from "effect";
if (Effect.runSync(answer) !== 42) throw new Error("Library returned the wrong value");
try {
await import("@repo/sdk/Answer");
throw new Error("Unlisted subpath was exposed");
} catch (error) {
if (error.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw error;
}
`,
);
yield* run(consumer, "node", ["index.mjs"]);
yield* fs.writeFileString(
path.join(consumer, "index.ts"),
'import { answer } from "@repo/sdk";\nimport { Effect } from "effect";\nconst typed: Effect.Effect<number> = answer;\nconst result: number = Effect.runSync(typed);\nvoid result;\n',
);
yield* run(consumer, "npm", [
"exec",
"--",
"tsc",
"--noEmit",
"--strict",
"--target",
"ES2022",
"--lib",
"ES2023,ESNext.Disposable,DOM,DOM.Iterable",
"--module",
"NodeNext",
"--moduleResolution",
"NodeNext",
"index.ts",
]);
yield* run(consumer, "npm", [
"exec",
"--",
"tsc",
"--noEmit",
"--strict",
"--target",
"ES2022",
"--lib",
"ES2023,ESNext.Disposable,DOM,DOM.Iterable",
"--module",
"ESNext",
"--moduleResolution",
"bundler",
"index.ts",
]);
yield* Console.log(
`${runtime}: tarball import, NodeNext and bundler declarations, private subpaths, and stale-output cleanup passed`,
);
}).pipe(Effect.scoped);

const Services = Layer.mergeAll(
BlueprintService.layer,
PlanService.layer,
ApplyService.layer,
).pipe(Layer.provide(OfficialCatalogLayer), Layer.provide(NodeServices.layer));

NodeRuntime.runMain(
Effect.gen(function* () {
yield* verify("bun");
yield* verify("node");
}).pipe(Effect.provide(Layer.mergeAll(Services, NodeServices.layer))),
);
Loading
Loading