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
2 changes: 2 additions & 0 deletions .agents/skills/darkmatter-repo-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Determine the repo's current state:
- Check for `.zed/`, `ops/`, `.github/`, `docs/`, `justfile`.

Classify the repo:

- **New repo**: no `package.json` or no `flake.nix`. Start from the
template and adapt.
- **Existing repo**: has some structure already. Audit against the
Expand Down Expand Up @@ -124,6 +125,7 @@ earlier ones:
14. **AGENTS.md** — last, because it documents everything else

Adaptation rules:

- Replace `ops-monorepo-demo` / `@ops-demo/web` with the target repo's
actual name and package names.
- Replace `Ops monorepo demo` in user-facing strings with the repo's
Expand Down
22 changes: 11 additions & 11 deletions .agents/skills/darkmatter-repo-setup/references/effect-solutions.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,17 @@ effect-solutions open-issue # leave feedback
These are the topics available via `effect-solutions show <topic>`.
Consult them when setting up or auditing Effect-related parts of a repo.

| Topic | What it covers | When to consult during setup |
| ----- | -------------- | ---------------------------- |
| `project-setup` | Effect Language Service installation, reference repositories for AI assistance | When configuring the tsconfig plugin and Zed LSP |
| `tsconfig` | Recommended TypeScript compiler settings for Effect (incremental, composite, ES2022, NodeNext, verbatimModuleSyntax, strict) | When writing or auditing tsconfig.json |
| `basics` | Coding conventions for `Effect.fn` and `Effect.gen` — sequencing, naming effectful functions | When scaffolding app source files |
| `services-and-layers` | `Context.Service` and `Layer` patterns for dependency injection — unique identifiers, composition, testability | When scaffolding `packages/web-core` services |
| `data-modeling` | `Schema` for records, variants, brands, pattern matching, JSON serialization — runtime validation + type safety | When defining config schemas and status payloads |
| `error-handling` | `Schema.TaggedError` for structured, serializable, type-safe domain errors | When defining error types |
| `config` | `Effect.Config` for type-safe configuration loading with validation, defaults, providers, and layer patterns | When scaffolding `AppConfig` or environment loading |
| `testing` | `@effect/vitest` with `it.effect()`, test layers, `TestClock`/`TestRandom` for deterministic tests | When scaffolding test files |
| `cli` | Effect's `CLI` module — typed argument parsing, automatic help, service integration | When the repo needs a CLI entrypoint |
| Topic | What it covers | When to consult during setup |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| `project-setup` | Effect Language Service installation, reference repositories for AI assistance | When configuring the tsconfig plugin and Zed LSP |
| `tsconfig` | Recommended TypeScript compiler settings for Effect (incremental, composite, ES2022, NodeNext, verbatimModuleSyntax, strict) | When writing or auditing tsconfig.json |
| `basics` | Coding conventions for `Effect.fn` and `Effect.gen` — sequencing, naming effectful functions | When scaffolding app source files |
| `services-and-layers` | `Context.Service` and `Layer` patterns for dependency injection — unique identifiers, composition, testability | When scaffolding `packages/web-core` services |
| `data-modeling` | `Schema` for records, variants, brands, pattern matching, JSON serialization — runtime validation + type safety | When defining config schemas and status payloads |
| `error-handling` | `Schema.TaggedError` for structured, serializable, type-safe domain errors | When defining error types |
| `config` | `Effect.Config` for type-safe configuration loading with validation, defaults, providers, and layer patterns | When scaffolding `AppConfig` or environment loading |
| `testing` | `@effect/vitest` with `it.effect()`, test layers, `TestClock`/`TestRandom` for deterministic tests | When scaffolding test files |
| `cli` | Effect's `CLI` module — typed argument parsing, automatic help, service integration | When the repo needs a CLI entrypoint |

## Usage in repo setup

Expand Down
1 change: 0 additions & 1 deletion .alchemy/version-check.json

This file was deleted.

53 changes: 27 additions & 26 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,38 @@ treating `ops/` as a junk drawer.

## Repository layout

| Path | Purpose |
| ----------------- | ------------------------------------------------------------ |
| `apps/web/` | Demo web app (`@ops-demo/web`) — Effect/Bun HTTP server |
| `packages/web-core/` | Framework-independent domain logic (`@repo/web-core`) |
| `packages/tooling/` | Shared TypeScript and Oxc configuration (`@repo/tooling`) |
| `flake.nix` | Root flake — stays at root because Nix discovers flakes there |
| `flake/` | Thin public Nix-output layer (apps, checks, devShells, packages) |
| `nix/demo/` | Nix package and smoke-check implementation |
| `nix/prelude.nix` | Prelude command catalogue (`x` menu, MOTD, docs) |
| `ops/` | Operational surface — see [ops/README.md](ops/README.md) |
| `tests/` | Cross-package smoke tests |
| `docs/` | Architecture and getting-started docs |
| `.github/workflows/` | CI pipeline |
| Path | Purpose |
| -------------------- | ---------------------------------------------------------------- |
| `apps/web/` | Demo web app (`@ops-demo/web`) — Effect/Bun HTTP server |
| `packages/web-core/` | Framework-independent domain logic (`@repo/web-core`) |
| `packages/tooling/` | Shared TypeScript and Oxc configuration (`@repo/tooling`) |
| `flake.nix` | Root flake — stays at root because Nix discovers flakes there |
| `flake/` | Thin public Nix-output layer (apps, checks, devShells, packages) |
| `nix/demo/` | Nix package and smoke-check implementation |
| `nix/prelude.nix` | Prelude command catalogue (`x` menu, MOTD, docs) |
| `ops/` | Operational surface — see [ops/README.md](ops/README.md) |
| `tests/` | Cross-package smoke tests |
| `docs/` | Architecture and getting-started docs |
| `.github/workflows/` | CI pipeline |

### `ops/` boundary

`ops/` owns the operational life of the repository. It is not a catch-all
for source-adjacent configuration. A Vite config, package manifest, or
application schema still belongs beside the application that uses it.

| Directory | Owns |
| ------------------ | ----------------------------------------------- |
| `ops/bin/` | Human-invoked operational commands |
| `ops/container/` | Container build recipes (Dockerfiles, bases) |
| `ops/compose/` | Local multi-service Docker Compose stacks |
| `ops/config/` | Runtime configuration for dependencies (Nginx, PostgreSQL, Redis) |
| `ops/deploy/` | Reusable deployment primitives (Kubernetes base, Terraform) |
| `ops/environments/`| Environment-specific assembly (dev, staging, production) |
| `ops/secrets/` | SOPS-encrypted secret material and rules |
| `ops/observability/` | Dashboards, alerts, metrics, tracing, logging |
| `ops/nix/` | Operational host/profile Nix configuration |
| `ops/policies/` | Guardrails evaluated by automation |
| Directory | Owns |
| -------------------- | ----------------------------------------------------------------- |
| `ops/bin/` | Human-invoked operational commands |
| `ops/container/` | Container build recipes (Dockerfiles, bases) |
| `ops/compose/` | Local multi-service Docker Compose stacks |
| `ops/config/` | Runtime configuration for dependencies (Nginx, PostgreSQL, Redis) |
| `ops/deploy/` | Reusable deployment primitives (Kubernetes base, Terraform) |
| `ops/environments/` | Environment-specific assembly (dev, staging, production) |
| `ops/secrets/` | SOPS-encrypted secret material and rules |
| `ops/observability/` | Dashboards, alerts, metrics, tracing, logging |
| `ops/nix/` | Operational host/profile Nix configuration |
| `ops/policies/` | Guardrails evaluated by automation |

## Tooling

Expand Down Expand Up @@ -103,7 +103,8 @@ Domain helpers live in `packages/web-core` so they can be tested without
the HTTP server:

- `AppConfig` — Effect service reading `HOST`, `PORT`, `APP_ENV`,
`APP_RELEASE` from environment config with defaults.
`APP_RELEASE`, and the optional redacted `DEMO_MESSAGE`; the web adapter can
add a SOPS document behind environment config through `APP_SOPS_FILE`.
- `Metrics` — in-memory request counter with Prometheus text export.
- `Status` — typed status payload encoded via `Schema`.

Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ docker compose -f ops/compose/local.yaml up --build

Set `OPS_DEMO_PORT` if port 3000 is already in use.

## SOPS config in the web app

The existing web app can add the checked-in encrypted Kubernetes Secret to its
Effect config provider chain through `alchemy-sops@0.8.1`. Environment values
remain primary, and the status endpoint reports only whether `DEMO_MESSAGE` was
configured; the decrypted value never enters the response.

Run the identity-free provider integration test:

```sh
bun run test -- apps/web/test/sops-config.test.ts
```

To run the app against the real decrypt path after replacing the demo SOPS
recipient with your team recipient, follow
[ops/secrets/README.md](ops/secrets/README.md).

## Layout

- `apps/` and `packages/` are application source and reusable code.
Expand Down
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
"dependencies": {
"@effect/platform-bun": "catalog:",
"@repo/web-core": "workspace:*",
"alchemy-sops": "catalog:",
"effect": "catalog:"
},
"devDependencies": {
"@effect/vitest": "catalog:",
"@repo/tooling": "workspace:*",
"@types/bun": "catalog:",
"typescript": "catalog:"
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { AppConfig } from "@repo/web-core";

Check warning on line 1 in apps/web/src/config.ts

View workflow job for this annotation

GitHub Actions / verify

effecttsgo(duplicate-package)

apps/web/src/config.ts:1:1: Multiple versions of package `@distilled.cloud/core` were detected: 0.30.3 @ /home/runner/work/template/template/node_modules/.bun/@distilled.cloud+core@0.30.3+ab8f4adbdde0342e/node_modules/@distilled.cloud/core, 1.0.0-rc.2 @ /home/runner/work/template/template/node_modules/.bun/@distilled.cloud+core@1.0.0-rc.2+ab8f4adbdde0342e/node_modules/@distilled.cloud/core. Package duplication can change runtime identity and type equality across Effect modules. If this is intentional, set the LSP config `allowedDuplicatedPackages` to ["@distilled.cloud/core"].
import * as SopsConfig from "alchemy-sops/Config";
import { Config, ConfigProvider, Effect, Layer, Option } from "effect";

interface AppConfigLayerOptions extends Pick<
SopsConfig.SopsConfigOptions,
"decrypt"
> {
readonly provider?: ConfigProvider.ConfigProvider;
}

export const makeAppConfigLive = (options: AppConfigLayerOptions = {}) => {
const environment = options.provider ?? ConfigProvider.fromEnv();

return Layer.unwrap(
Config.option(Config.string("APP_SOPS_FILE"))
.parse(environment)
.pipe(
Effect.map(
Option.match({
onNone: () => environment,
onSome: (path) =>
environment.pipe(
ConfigProvider.orElse(
SopsConfig.make({
path,
format: "yaml",
backend: "sops-age",
secrets: {
DEMO_MESSAGE: "stringData.DEMO_MESSAGE",
},
...(options.decrypt ? { decrypt: options.decrypt } : {}),
}),
),
),
}),
),
Effect.map((provider) =>
AppConfig.layer.pipe(Layer.provide(ConfigProvider.layer(provider))),
),
),
);
};

export const AppConfigLive = makeAppConfigLive();
4 changes: 3 additions & 1 deletion apps/web/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BunHttpServer, BunRuntime } from "@effect/platform-bun";

Check warning on line 1 in apps/web/src/server.ts

View workflow job for this annotation

GitHub Actions / verify

effecttsgo(duplicate-package)

apps/web/src/server.ts:1:1: Multiple versions of package `@distilled.cloud/core` were detected: 0.30.3 @ /home/runner/work/template/template/node_modules/.bun/@distilled.cloud+core@0.30.3+ab8f4adbdde0342e/node_modules/@distilled.cloud/core, 1.0.0-rc.2 @ /home/runner/work/template/template/node_modules/.bun/@distilled.cloud+core@1.0.0-rc.2+ab8f4adbdde0342e/node_modules/@distilled.cloud/core. Package duplication can change runtime identity and type equality across Effect modules. If this is intentional, set the LSP config `allowedDuplicatedPackages` to ["@distilled.cloud/core"].
import { AppConfig, Metrics, Status } from "@repo/web-core";
import { Config, Effect, Layer } from "effect";
import {
Expand All @@ -8,6 +8,8 @@
} from "effect/unstable/http";
import { fileURLToPath } from "node:url";

import { AppConfigLive } from "./config.ts";

const packagedPublicDirectory = fileURLToPath(
new URL("../public", import.meta.url),
);
Expand Down Expand Up @@ -64,7 +66,7 @@
Layer.provide(Status.layer),
Layer.provide(Metrics.layer),
Layer.provide(ServerBackend),
Layer.provide(AppConfig.layer),
Layer.provide(AppConfigLive),
);

BunRuntime.runMain(Layer.launch(MainLive));
62 changes: 62 additions & 0 deletions apps/web/test/sops-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { assert, it } from "@effect/vitest";

Check warning on line 1 in apps/web/test/sops-config.test.ts

View workflow job for this annotation

GitHub Actions / verify

effecttsgo(duplicate-package)

apps/web/test/sops-config.test.ts:1:1: Multiple versions of package `@distilled.cloud/core` were detected: 0.30.3 @ /home/runner/work/template/template/node_modules/.bun/@distilled.cloud+core@0.30.3+ab8f4adbdde0342e/node_modules/@distilled.cloud/core, 1.0.0-rc.2 @ /home/runner/work/template/template/node_modules/.bun/@distilled.cloud+core@1.0.0-rc.2+ab8f4adbdde0342e/node_modules/@distilled.cloud/core. Package duplication can change runtime identity and type equality across Effect modules. If this is intentional, set the LSP config `allowedDuplicatedPackages` to ["@distilled.cloud/core"].
import type { SopsCommandRequest } from "alchemy-sops";
import { AppConfig } from "@repo/web-core";
import { ConfigProvider, Effect } from "effect";

import { makeAppConfigLive } from "#config.ts";

it.effect("loads the existing app config through the SOPS fallback", () => {
const requests: SopsCommandRequest[] = [];
const provider = ConfigProvider.fromUnknown({
APP_ENV: "test",
APP_SOPS_FILE: "ops/secrets/demo.sops.yaml",
});
const config = makeAppConfigLive({
provider,
decrypt: (request) =>
Effect.sync(() => {
requests.push(request);
return "stringData:\n DEMO_MESSAGE: synthetic-provider-value\n";
}),
});

return Effect.gen(function* () {
const appConfig = yield* AppConfig;

assert.strictEqual(appConfig.environment, "test");
assert.strictEqual(appConfig.demoMessageConfigured, true);
assert.strictEqual(requests.length, 1);

const request = requests[0]!;
assert.match(request.path ?? "", /ops\/secrets\/demo\.sops\.yaml$/);
assert.strictEqual(request.binary, "sops");
assert.strictEqual(request.inputType, "yaml");
assert.strictEqual(request.outputType, "yaml");
}).pipe(Effect.provide(config));
});

it.effect("keeps environment config primary without decrypting SOPS", () => {
let decryptions = 0;
const provider = ConfigProvider.fromUnknown({
HOST: "127.0.0.1",
PORT: "3000",
APP_ENV: "test",
APP_RELEASE: "test",
APP_SOPS_FILE: "ops/secrets/demo.sops.yaml",
DEMO_MESSAGE: "from-environment",
});
const config = makeAppConfigLive({
provider,
decrypt: () => {
decryptions += 1;
return Effect.succeed("stringData:\n DEMO_MESSAGE: unused\n");
},
});

return Effect.gen(function* () {
const appConfig = yield* AppConfig;

assert.strictEqual(appConfig.demoMessageConfigured, true);
assert.strictEqual(decryptions, 0);
}).pipe(Effect.provide(config));
});
Loading
Loading