Below is the Effect 4 shape I’d create for a short top-level mlink CLI.
Commands
No links prefix:
mlink health
mlink list [--q] [--status active|tombstoned|all] [--kind alias|generated|all] [--limit] [--cursor] [--all]
mlink get <slug>
mlink create <destination>
mlink alias <slug> <destination>
mlink retarget <slug> <destination>
mlink tombstone <slug>
mlink restore <slug>
mlink metrics <slug>Files I’d create
apps/mulroy.link/link-manager/src/cli/
main.ts
Cli.ts
Config.ts
Errors.ts
LinkManagerOperatorClient.ts
Output.tsAnd I’d modify:
apps/mulroy.link/link-manager/src/http/operator/OperatorApi.ts
apps/mulroy.link/link-manager/src/http/operator/OperatorHandlers.ts
apps/mulroy.link/link-manager/package.jsonExisting schemas to reuse
From src/http/operator/OperatorContracts.ts:
LinkResponse
LinkListResponse
LinkMetricsResponse
HealthyResponse
DegradedHealthResponse
OperatorIdentityResponseFrom src/http/operator/OperatorRequestBodies.ts:
AliasDestinationRequest
LinkListQueryRequestFrom src/link-catalog/Link.ts:
LinkListStatusFilter
LinkListKindFilter
parseLinkSlug
parseDestinationUrlAPI change first
I’d make mutation bodies typed in OperatorApi.ts.
Current raw handlers should become typed payload endpoints:
createGeneratedLink:
POST /api/links
payload: AliasDestinationRequest
createAlias:
PUT /api/links/:slug
payload: AliasDestinationRequest
retargetAlias:
PATCH /api/links/:slug
payload: AliasDestinationRequestThat lets the CLI use HttpApiClient.make(LinkManagerApi) instead of manually building JSON requests.
New schemas
CliOutputMode = "human" | "json"
CliAccessCredentials =
| { kind: "jwt"; jwt: Redacted }
| {
kind: "service_token";
clientId: Redacted;
clientSecret: Redacted;
}
| { kind: "none" }
CliConfig = {
baseUrl: string;
outputMode: CliOutputMode;
access: CliAccessCredentials;
}Inputs from:
MULROY_LINK_BASE_URL
MULROY_LINK_ACCESS_JWT
MULROY_LINK_ACCESS_CLIENT_ID
MULROY_LINK_ACCESS_CLIENT_SECRETGlobal flags can override env:
--base-url
--json
--access-jwt
--access-client-id
--access-client-secretNew errors
Use Schema.TaggedErrorClass.
CliConfigError
CliAuthError
CliApiError
CliDecodeError
CliUsageErrorThe operator client should map lower-level errors into CLI-level errors:
HttpClientError.HttpClientError
Schema.SchemaError
OperatorErrorResponse
Cloudflare Access errorsThe command layer should only need to handle CliError.
Services / tags
export class CliConfig extends Context.Service<
CliConfig,
{
readonly baseUrl: string;
readonly outputMode: "human" | "json";
readonly access: CliAccessCredentials;
}
>()("link-manager/cli/CliConfig") {}Layers:
CliConfig.layerFromEnv
CliConfig.layerFromFlags(...)
CliConfig.layerTest(...) export class LinkManagerOperatorClient extends Context.Service<
LinkManagerOperatorClient,
{
readonly health: Effect.Effect<HealthResponse, CliError>;
readonly list: (
query: LinkListQueryRequest,
) => Effect.Effect<LinkListResponse, CliError>;
readonly get: (
slug: string,
) => Effect.Effect<LinkResponse, CliError>;
readonly create: (
destination: string,
) => Effect.Effect<LinkResponse, CliError>;
readonly alias: (
input: { readonly slug: string; readonly destination: string },
) => Effect.Effect<LinkResponse, CliError>;
readonly retarget: (
input: { readonly slug: string; readonly destination: string },
) => Effect.Effect<LinkResponse, CliError>;
readonly tombstone: (
slug: string,
) => Effect.Effect<LinkResponse, CliError>;
readonly restore: (
slug: string,
) => Effect.Effect<LinkResponse, CliError>;
readonly metrics: (
slug: string,
) => Effect.Effect<LinkMetricsResponse, CliError>;
}
>()("link-manager/cli/LinkManagerOperatorClient") {}Layer:
LinkManagerOperatorClient.layerDepends on:
CliConfig
HttpClient.HttpClientInternally:
HttpApiClient.make(LinkManagerApi, {
baseUrl: config.baseUrl,
transformClient: addAccessHeaders(config.access),
}) export class CliOutput extends Context.Service<
CliOutput,
{
readonly link: (link: LinkResponse) => Effect.Effect<void>;
readonly list: (page: LinkListResponse) => Effect.Effect<void>;
readonly metrics: (metrics: LinkMetricsResponse) => Effect.Effect<void>;
readonly health: (health: HealthResponse) => Effect.Effect<void>;
readonly error: (error: CliError) => Effect.Effect<void>;
}
>()("link-manager/cli/CliOutput") {}Layers:
CliOutput.layerHuman
CliOutput.layerJson
CliOutput.layerCliOutput.layer chooses based on CliConfig.outputMode.
Layer graph
Production CLI layer:
const CliLive = Layer.mergeAll(
CliConfig.layerFromEnv,
LinkManagerOperatorClient.layer,
CliOutput.layer,
FetchHttpClient.layer,
);For tests:
const CliTest = Layer.mergeAll(
CliConfig.layerTest(...),
LinkManagerOperatorClient.layerFromHandler(makeInMemoryLinkManagerHandler(...)),
CliOutput.layerMemory,
);Call stacks
main.ts
Command.runWith(rootCommand)
Cli.ts list command handler
LinkManagerOperatorClient.list(query)
HttpApiClient client.operator.listLinks({ query })
GET {baseUrl}/api/links
Worker fetch
OperatorApi listLinks
OperatorHandlers.listLinks
LinkCatalog.listLinks
LinkCatalog.layerDurableObject
Durable Object stub.listLinks
LinkCatalogStore.listLinks
SQLite DO storage
CliOutput.list(page)mlink alias gh https://github.com/dmmulroy
main.ts
Command.runWith(rootCommand)
Cli.ts alias command handler
optional local parseLinkSlug("gh")
optional local parseDestinationUrl("https://github.com/dmmulroy")
LinkManagerOperatorClient.alias({ slug, destination })
HttpApiClient client.operator.createAlias({
params: { slug },
payload: { destination }
})
PUT {baseUrl}/api/links/gh
Cloudflare Access middleware
ProvideOperatorIdentity middleware
OperatorHandlers.createAlias
LinkCatalog.getLink(slug)
LinkCatalog.createAlias({ slug, destination, operator })
parseLinkSlug
parseDestinationUrl
LinkCatalogCoordinator.createAlias
LinkCatalogStore.insertAlias
PublicRedirectIndexService.putDestination
CliOutput.link(link)mlink retarget gh https://example.com
Cli command
LinkManagerOperatorClient.retarget
PATCH /api/links/:slug
OperatorHandlers.retargetAlias
LinkCatalog.retargetAlias
LinkCatalogCoordinator.retargetAlias
store.getAlias
store.updateAliasDestination
redirectIndex.putDestination
rollback store.replaceLinkForRollback on index failure
Output.link Cli command
LinkManagerOperatorClient.tombstone
DELETE /api/links/:slug
OperatorHandlers.tombstoneLink
LinkCatalog.tombstoneLink
LinkCatalogCoordinator.tombstoneLink
store.getLink
store.tombstoneLink
redirectIndex.deleteDestination
Output.link Cli command
LinkManagerOperatorClient.metrics
GET /api/links/:slug/metrics
OperatorHandlers.getLinkMetrics
LinkCatalog.getLinkMetrics
LinkCatalogStore.getLinkMetrics
Output.metricsCli.ts command structure
I’d define one root command with top-level subcommands:
const rootCommand = Command.make("mlink").pipe(
Command.withDescription("Manage mulroy.link redirects."),
Command.withSubcommands([
health,
list,
get,
create,
alias,
retarget,
tombstone,
restore,
metrics,
]),
);Each command handler should be very thin:
const get = Command.make(
"get",
{ slug: Argument.string("slug") },
({ slug }) =>
Effect.gen(function* () {
const client = yield* LinkManagerOperatorClient;
const output = yield* CliOutput;
const link = yield* client.get(slug);
yield* output.link(link);
}),
);main.ts
const run = Command.runWith(rootCommand, {
version: "0.1.0",
});
NodeRuntime.runMain(
run(process.argv.slice(2)).pipe(
Effect.provide(CliLive),
),
);Package changes
In package.json I’d add:
{
"bin": {
"mlink": "./dist/cli/main.js"
},
"scripts": {
"cli": "tsx src/cli/main.ts"
}
}Potential dev dependency if needed:
{
"devDependencies": {
"tsx": "catalog:"
}
}