원클릭으로
migration-service
Migrate a server-shaped project to forge — services, operators, workers, webhooks, packs, multi-binary cmd/, k8s manifests.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Migrate a server-shaped project to forge — services, operators, workers, webhooks, packs, multi-binary cmd/, k8s manifests.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Visual-design discipline for forge frontends — brief the work before building (never invent an aesthetic without user input), declare the design system before coding, lean on the component library, use restrained color/type systems, never hand-draw complex SVGs, and verify visually before declaring done.
Write Next.js frontends — generated hooks, component library, Tailwind v4, visual verification, and Connect RPC clients.
Outbound boundary translators. One adapter per third-party system / queue / storage backend; narrow interface, vendor-neutral callers.
Write Connect RPC handlers — proto-driven codegen, the thin-translation handler pattern (validate, extract auth, convert proto↔internal, call service, wrap errors via `svcerr.Wrap`), middleware, and testing. Business logic lives in `internal/handlers/<svc>/contract.go`, never in handlers.
Forge project conventions and architecture — project structure, generated vs hand-written code, the generate pipeline, proto annotations, contracts, wiring, and naming.
Use-case orchestrators that compose two or more adapters/services. Deps are interfaces only — designed for unit tests with all-mock collaborators.
| name | migration-service |
| description | Migrate a server-shaped project to forge — services, operators, workers, webhooks, packs, multi-binary cmd/, k8s manifests. |
Use this skill when the existing project is network-facing: HTTP/gRPC servers, background workers, webhook receivers, k8s deployments. For CLI / library shapes see migration-cli. For prerequisites and the overall flow see migration.
forge new <name>-next \
--kind service \
--mod github.com/<owner>/<name>-next \
--service <main-service> \
--frontend <main-fe>
--kind service is the default — emitting it explicitly documents intent. Pass --service and --frontend for the primary network-facing component(s); add the rest one at a time after scaffold so each can be verified in isolation.
forge add service <name> # additional Connect-RPC services
forge add operator <name> # k8s operator / controller loop
forge add worker <name> # background worker (Start/Stop lifecycle)
forge add worker <name> --kind cron --schedule "..." # cron worker
forge add webhook <name> --service <existing-service> # webhook on an existing service
Run forge generate && go build ./... after each. Hyphens are OK in names; forge stores the hyphenated form as the display name and snake-cases the directory and Go package paths.
Order matters. A stripe webhook receiver needs the host service to exist before pack install can wire its handler:
forge add service billing
forge pack install stripe
forge generate
Available packs: jwt-auth, clerk, firebase-auth, api-key, audit-log, data-table, auth-ui. Starters (one-time copies): stripe, twilio, clerk-webhook. See packs / starters skills for details.
Forge deliberately does NOT ship a NATS / Kafka / generic-queue pack. Wire-format conventions (subject naming, message envelopes, retry/DLQ shape) are too project-specific — what works for one team's JetStream layout is wrong for the next. For NATS-using projects, install github.com/nats-io/nats.go directly and write a thin wrapper under internal/<name>/ with a contract.go exposing your actual publish/subscribe surface. Same applies to Kafka (github.com/segmentio/kafka-go), RabbitMQ, etc. Use the adapter skill for the wrapper shape.
These have all surfaced in real migrations. Spot the symptom, apply the fix immediately:
auditlog.Interceptor in internal/middleware/audit/auditlog/ (lives in its own Go subpackage so it cannot collide with the scaffold's slog-only middleware.AuditInterceptor). Wire one or the other in the explicit composition in internal/app/compose.go (NewComponents), not both — they record the same events.keyfunc.Keyfunc.Cancel() API. Fixed; if you see it again on a fresh fork, file the bug.db.v1, NOT <project>.db.v1. Proto package names align with the buf module root, not the project name. Templates that try to prefix the project name will lint-fail.webhookMaxBodySize, webhookEvent, extractEventID, verifyHMACSHA256). Fixed; webhook templates now use unique names per webhook.forge generate
go mod tidy
go build ./...
forge lint
All four must pass on the empty scaffold. Fix any failures here, not after porting — the failures will be much easier to read against zero ported code.
Before porting any internal package, edit forge.yaml:
contracts:
strict: true
allow_exported_vars: false
allow_exported_funcs: false
exclude: []
Then forge lint --contract is part of the per-phase gate. See the contracts skill for the per-package design pattern (interface in contract.go first, forge generate, then implementation behind the interface).
db/migrations/ plus any hand-written query files). Migrations are the schema source of truth — copy them as-is. forge generate shadow-applies them and projects the entity structs/ORM into internal/db/<entity>_orm.go for every table that also has CRUD RPCs in a service proto; don't port the source repo's generated ORM or entity types. Write plain postgres DDL (see the db skill) — the shadow applies migrations verbatim to a real ephemeral postgres; auxiliary DDL the bare DB can't satisfy is skipped.internal/handlers/<svc>/). A service is one co-located directory under the internal/handlers/ role subtree: hand-written contract.go + impl alongside the generated handlers_gen.go / CRUD / authorizer stubs. The *_gen.go files get rewritten on every forge generate; only your hand-written code (the contract.go interface, its implementation, domain types) moves over. There is no separate top-level handlers/<svc>/ tier to port into — collapse any source split into the one internal/handlers/<svc>/.internal/app/compose.go NewComponents, off the owned internal/app/providers.go Infra/OpenInfra). NewComponents(infra *Infra) (*Components, error) constructs every component inline in type-topological order and fills each component's interface-typed Deps off infra.<Field>, resolved BY TYPE. providers.go (the owned Infra provider set + OpenInfra) is yours, scaffolded once; compose.go is forge-owned and regenerated (forge disown internal/app/compose.go to hand-own — e.g. for late-bound/two-phase construct-then-inject setters). Port the source repo's wiring intent by declaring each collaborator on Infra and filling the Deps fields in NewComponents; do NOT expect a regenerated bootstrap.go / wire_gen.go to wire things for you — that name-matched layer no longer exists.internal/workers/<name>/ and internal/operators/<name>/ (webhooks attach to their service under internal/handlers/<svc>/); implement the lifecycle methods (Start, Stop, Reconcile, webhook event handlers). They are constructed in NewComponents and surfaced through the generated WorkerList/OperatorList over *Components in internal/app/lifecycle.go.A 1-for-1 port is the goal, but some source patterns are smells that forge's defaults will surface. Fixing them at port time is cheap; fixing them later is a refactor across every caller.
If the source has a single wide Repository interface with many methods (a "god DAO"), DO NOT path-exclude interfacebloat and move on. The lint is correct: the interface is too wide. At port time you have two clean options:
Repository also have narrow per-aggregate interfaces (UserRepository, OrgRepository, BillingRepository) sitting next to it. The concrete *PostgresRepository satisfies all of them structurally — Go does that for free. Each caller depends on the 10-method interface it actually needs. interfacebloat passes naturally because no individual interface is over the limit.internal/db/user/, internal/db/org/, internal/db/billing/, each with its own contract.go + narrow Service. The aggregate-per-package pattern. Larger refactor; cleaner long-term.Exception: sqlc-generated code. sqlc emits one method per query into a single Queries struct, and you can't split that output across packages. If your source uses sqlc (check forge.yaml: sqlc_enabled: true or look for sqlc.yaml), the wide interface is a generated artifact — interfacebloat is a false positive against it. In that one case, add a path-based exclusion for the generated dir AND document it ("generated by sqlc; cannot split"). For hand-written DAOs, split.
No Service alias needed when you split. Splitting a wide DAO into narrow per-aggregate interfaces (UserRepository, OrgRepository, …) is done — there is no name-matched generator that assumes every package exposes a Service. The composition resolves deps by type, not by name: a consumer that needs audit.Repository is filled from whatever concrete value on the Infra provider set structurally satisfies it (*db.PostgresRepository either satisfies audit.Repository or the code does not compile). Each caller depends on the narrow interface it actually needs; interfacebloat passes because no individual interface is over the limit. Drop the umbrella type Service interface { ... } — it only ever existed to feed the deleted generators.
The composition (internal/app/compose.go NewComponents, off the owned internal/app/providers.go Infra/OpenInfra) resolves deps by type. Adding or removing a collaborator means editing the component's Deps struct in internal/handlers/<svc>/contract.go and ensuring the matching field exists on the owned Infra provider set so NewComponents can fill it by type. Both are caught by the Go compiler (or surface as a forge generate "no provider" report if Infra lacks the type). If a port phase drops a vestigial Logger field from <pkg>.Deps, regen stops filling it; go build points you straight at any stale reference. Do not look for a bootstrap.go / wire_gen.go to chase — there is no name-matched layer; the wiring is NewComponents filling typed Deps off Infra.
If the source uses goose (one-file migrations with -- +goose Up / -- +goose Down markers), forge expects golang-migrate (two-file .up.sql + .down.sql). The conversion is mechanical for the common case:
-- +goose Down line into two files.-- +goose StatementBegin / -- +goose StatementEnd markers (they wrap single statements; golang-migrate handles that natively).-- +goose NO TRANSACTION translate to a golang-migrate x-no-tx-wrap header on that file.If source migrations have foreign-key dependencies on tables that pack migrations create (or vice versa), reorder carefully. Pack migrations are not negotiable; renumber yours.
If a test passes in the source repo but fails in the cp-forge port, it is always a port bug. Never write "pre-existing in source" in a synthesis or friction note without first running go test ./<same-package>/... against the source tree to verify. The v2 migration of control-plane had a synthesis agent declare three svcbilling tests as "pre-existing source failures" — verified false; source passed, port failed. The regressions were real port bugs (wrong entitlement-org vs first-org selection logic).
Concrete rule for the final gate: before declaring a migration complete, the synthesizing agent MUST:
go test -count=1 ./... in the cp-forge tree, capture the failing test names.internal/service/<x>/ vs internal/<x>/).The cost of doing this right is one go test invocation per failing test. The cost of getting it wrong is shipping a half-broken port and discovering it weeks later when the failing path matters.
//nolint:, never path-excludeWhen forge lint fires on a freshly-ported package, the temptation is to add internal/<pkg>/ to a path-based exclusion list in .golangci.yml. Don't. That silences every linter on the package — including the bug-catchers (errcheck, govet, staticcheck, unused) — to make today's port land. You will pay for it in subtle bugs across that package's lifetime.
The right responses, in order of preference:
gocognit / funlen / nestif flags point at a function that genuinely benefits from being split. interfacebloat points at a god-interface that should be split (see above). Take the small refactor.//nolint:gocognit // ported as-is from <source-path>; rewriting risks behavior drift at the function declaration. Per-line, with a justification comment. Reviewers and future-you can see exactly what was exempted and why. Standard Go convention.gen/, internal/<pkg>/embed.go, or a sqlc output dir. Never on hand-written code.Forge's defaults are opinionated by design. A clean port should land with at most a handful of //nolint: annotations, not a growing list of yaml path exclusions.
cmd/ layoutscmd/ is entrypoints only: one cobra root (cmd/main.go) plus one real per-command subcommand file (cmd/<svc>.go, cmd/<binary>.go), each driving the serve path (app.OpenInfra → app.NewComponents → the generated (*Components).Mount<Svc> onto serverkit.Server → serverkit.Run). forge add service adds the subcommand + its internal/handlers/<svc>/. If the source repo has additional binaries (CLI tools, background daemons, ops scripts) that aren't first-class forge components:
forge add and let forge own the wiring.forge add binary <name> — it becomes a peer subcommand with its own composition (OpenInfra → NewComponents). See the binaries skill for the decision tree.cmd/<name>.go subcommand on the root without an internal/<name>/ package.Forge emits deploy/kcl/<env>/ (KCL-based manifests, one dir per environment: dev, staging, prod). KCL is canonical — there is no "disable KCL, ship hand-written YAML" mode. Either:
additional_manifests = [...] on the Bundle for raw manifest dicts that don't fit a typed entity (ClusterIssuers, SealedSecrets, hand-typed CRDs). Recommended.features.deploy: false in forge.yaml and bring your own manifests under any tree you like. forge deploy <env> and the deploy half of forge generate then short-circuit with a clear "feature 'deploy' is disabled" message.forge.yaml stays strictly top-level (project identity, features, deploy provider). Per-env config (logging, env vars) lives directly in deploy/kcl/<env>/ alongside the per-env deploy knobs (cluster/namespace/registry/domain) on forge.K8sCluster blocks — there is no redundant second config.<env>.yaml YAML format. The single typed config struct (internal/config, generated from proto config blocks) serves server, CLI, and standalone binaries alike via cmdkit; non-server binaries do NOT hand-roll os.Getenv/ad-hoc loggers/hardcoded timeouts.
forge generate # idempotent on a healthy project
forge lint # contract + db + general lints
forge build # binaries + frontends + Docker images
forge test # unit + integration
forge test e2e # full-stack (requires `forge up --env=dev` in another shell)
forge deploy dev # local k3d
forge generate. Never the reverse.internal/app/compose.go (NewComponents) filling typed Deps by type off the owned internal/app/providers.go Infra/OpenInfra — there is no generated bootstrap.go/wire_gen.go.forge generate after every forge add and every forge pack install. It's idempotent and catches misconfigurations early.migration-cli.contracts.packs, auth, and the per-pack docs in forge pack list.