diff --git a/.changeset/remove-programmatic-schema-mutation.md b/.changeset/remove-programmatic-schema-mutation.md new file mode 100644 index 000000000..16ddf8bd5 --- /dev/null +++ b/.changeset/remove-programmatic-schema-mutation.md @@ -0,0 +1,11 @@ +--- +"arkenv": major +--- + +#### Remove programmatic AST schema mutation and preset command + +The `arkenv preset apply` and `arkenv preset remove` commands, along with programmatic AST and comment-marker schema mutation, have been removed from the CLI. + +Hosting presets (Vercel, Netlify, Cloudflare, Railway, Render, Fly.io) remain available during initial project scaffolding via `arkenv init --preset ` and are documented as copyable code snippets in the docs. + +**BREAKING CHANGE**: The `arkenv preset` command and `// @arkenv-preset-start` comment marker management have been removed. Use `arkenv init --preset ` when scaffolding new projects, or copy provider variable definitions directly into `./env.ts` for existing schemas. diff --git a/apps/www/content/docs/core-concepts/hosting-presets.mdx b/apps/www/content/docs/core-concepts/hosting-presets.mdx index 94ddb9a44..1192176fa 100644 --- a/apps/www/content/docs/core-concepts/hosting-presets.mdx +++ b/apps/www/content/docs/core-concepts/hosting-presets.mdx @@ -1,11 +1,11 @@ --- title: Hosting presets -description: Pre-populate schemas with Vercel, Netlify, Cloudflare, Railway, Render, or Fly.io variables using managed preset blocks. +description: Pre-populate schemas with Vercel, Netlify, Cloudflare, Railway, Render, or Fly.io environment variables. --- -Hosting providers inject their own system environment variables at build and -runtime. Presets teach the ArkEnv CLI which variables to generate into your -schema, pre-typed and optional, so you do not look them up manually. +Hosting providers inject system environment variables at build and +runtime. Presets teach ArkEnv which variables to generate into your +schema, pre-typed and optional, so you do not have to look them up manually. Presets work with ArkType, Zod, and Valibot on a flat `env.ts` schema. @@ -31,118 +31,351 @@ Accepted values: `none`, `vercel`, `netlify`, `cloudflare`, `railway`, `render`, The CLI also accepts `-P`, `--host-preset`, or `-H` as aliases. Passing `none` scaffolds standard schema templates without provider fields. -## Manage presets in an existing schema +## Adding presets to an existing schema -You can apply, refresh, or remove hosting presets at any time using the -`arkenv preset` command. +Because ArkEnv is code-first and generates plain TypeScript schemas, you can copy +and paste hosting provider fields directly into your `./env.ts` at any time. -### Apply or refresh a preset - -```package-install -npx arkenv preset apply vercel -``` - -When you run `preset apply`, the CLI: - -1. Resolves your schema file location automatically from the `"arkenv"` configuration pointer in `package.json` (or defaults to standard paths). -2. Verifies that your git working tree is clean to prevent uncommitted loss. -3. Checks for collisions against existing user-defined variables or other presets. -4. Injects or refreshes the platform variables within delimited comment markers. -5. Updates `.env.example` with the new keys. - -To specify a custom schema path explicitly, pass `--file`: - -```package-install -npx arkenv preset apply vercel --file ./src/config/env.ts -``` - -### Remove a preset - -```package-install -npx arkenv preset remove vercel -``` - -The `preset remove` command strips the delimited preset block from your schema -and removes provider-specific entries from `.env.example`, while preserving any -keys shared with other active presets or custom fields. - -## How managed preset blocks work - -ArkEnv isolates machine-managed platform variables using comment markers: - -```ts title="./env.ts" -import arkenv from "@arkenv/core"; - -export const env = arkenv({ - DATABASE_URL: "string", - PORT: "number.port = 3000", - - // @arkenv-preset-start vercel - VERCEL: "string?", - VERCEL_ENV: "'production' | 'preview' | 'development'?", - VERCEL_URL: "string?", - // @arkenv-preset-end vercel -}); -``` - -### Why comment markers exist - -1. **Handling unprefixed platform keys**: While some platforms prefix variables (e.g. `VERCEL_ENV`), many providers use common names like `URL`, `CONTEXT`, `DEPLOY_URL` (Netlify), or `PORT` (Railway, Render, Fly.io). Markers establish unambiguous machine ownership so the CLI knows which fields belong to the host. -2. **Safe atomic updates**: When hosting platforms add new system environment variables, re-running `arkenv preset apply ` replaces only the delimited block, leaving your manual schema customizations before and after untouched. -3. **Clean removal**: Running `arkenv preset remove ` deletes only the target provider block without risking collateral deletion of your own fields. -4. **Validator and AST independence**: Because ArkEnv supports multiple validators (ArkType DSL, Zod objects, Valibot pipes, Standard Schema), line-delimited boundaries avoid heavyweight AST formatting passes that alter user indentation or drop code comments. +### Vercel -### Runtime and build behavior + + + ```ts title="env.ts" + import arkenv from "@arkenv/core"; + + export const env = arkenv({ + DATABASE_URL: "string", + + // Vercel system environment variables + VERCEL: "string?", + VERCEL_ENV: "'production' | 'preview' | 'development'?", + VERCEL_URL: "string?", + }); + ``` + + + + ```ts title="env.ts" + import arkenv from "@arkenv/standard"; + import { z } from "zod"; + + export const env = arkenv({ + DATABASE_URL: z.string(), + + // Vercel system environment variables + VERCEL: z.string().optional(), + VERCEL_ENV: z.enum(["production", "preview", "development"]).optional(), + VERCEL_URL: z.string().optional(), + }); + ``` + + + + ```ts title="env.ts" + import arkenv from "@arkenv/standard"; + import * as v from "valibot"; + + export const env = arkenv({ + DATABASE_URL: v.string(), + + // Vercel system environment variables + VERCEL: v.optional(v.string()), + VERCEL_ENV: v.optional(v.picklist(["production", "preview", "development"])), + VERCEL_URL: v.optional(v.string()), + }); + ``` + + -Comment markers are plain TypeScript/JavaScript comments. They have **zero performance impact** and are stripped by bundlers and TypeScript compilers during build. ArkEnv's runtime validation treats marked variables as ordinary schema fields. +### Netlify -### Safety mechanisms + + + ```ts title="env.ts" + import arkenv from "@arkenv/core"; + + export const env = arkenv({ + DATABASE_URL: "string", + + // Netlify system environment variables + NETLIFY: "string?", + DEPLOY_URL: "string?", + CONTEXT: "'production' | 'deploy-preview' | 'branch-deploy'?", + URL: "string?", + }); + ``` + + + + ```ts title="env.ts" + import arkenv from "@arkenv/standard"; + import { z } from "zod"; + + export const env = arkenv({ + DATABASE_URL: z.string(), + + // Netlify system environment variables + NETLIFY: z.string().optional(), + DEPLOY_URL: z.string().optional(), + CONTEXT: z.enum(["production", "deploy-preview", "branch-deploy"]).optional(), + URL: z.string().optional(), + }); + ``` + + + + ```ts title="env.ts" + import arkenv from "@arkenv/standard"; + import * as v from "valibot"; + + export const env = arkenv({ + DATABASE_URL: v.string(), + + // Netlify system environment variables + NETLIFY: v.optional(v.string()), + DEPLOY_URL: v.optional(v.string()), + CONTEXT: v.optional(v.picklist(["production", "deploy-preview", "branch-deploy"])), + URL: v.optional(v.string()), + }); + ``` + + + +### Cloudflare Pages / Workers + + + + ```ts title="env.ts" + import arkenv from "@arkenv/core"; + + export const env = arkenv({ + DATABASE_URL: "string", + + // Cloudflare system environment variables + CF_PAGES: "string?", + CF_PAGES_COMMIT_SHA: "string?", + CF_PAGES_BRANCH: "string?", + CF_PAGES_URL: "string?", + }); + ``` + + + + ```ts title="env.ts" + import arkenv from "@arkenv/standard"; + import { z } from "zod"; + + export const env = arkenv({ + DATABASE_URL: z.string(), + + // Cloudflare system environment variables + CF_PAGES: z.string().optional(), + CF_PAGES_COMMIT_SHA: z.string().optional(), + CF_PAGES_BRANCH: z.string().optional(), + CF_PAGES_URL: z.string().optional(), + }); + ``` + + + + ```ts title="env.ts" + import arkenv from "@arkenv/standard"; + import * as v from "valibot"; + + export const env = arkenv({ + DATABASE_URL: v.string(), + + // Cloudflare system environment variables + CF_PAGES: v.optional(v.string()), + CF_PAGES_COMMIT_SHA: v.optional(v.string()), + CF_PAGES_BRANCH: v.optional(v.string()), + CF_PAGES_URL: v.optional(v.string()), + }); + ``` + + -- **Fail-closed collision prevention**: If a variable defined by a preset already exists outside a managed block, `arkenv preset apply` halts immediately and refuses to modify the file. It will never overwrite your manual schema definitions. -- **Git working tree gate**: All file mutations require a clean git working tree so any changes appear clearly in `git diff`. Pass `--force` (`-f`) if you want to bypass this check in automated environments. -- **Malformed marker detection**: If marker comments are unclosed or mismatched, the CLI halts with a descriptive error before touching code. +### Railway -## Stacking multiple presets + + + ```ts title="env.ts" + import arkenv from "@arkenv/core"; + + export const env = arkenv({ + DATABASE_URL: "string", + + // Railway system environment variables + RAILWAY_ENVIRONMENT_NAME: "string?", + RAILWAY_PUBLIC_DOMAIN: "string?", + RAILWAY_SERVICE_NAME: "string?", + RAILWAY_GIT_COMMIT_SHA: "string?", + }); + ``` + + + + ```ts title="env.ts" + import arkenv from "@arkenv/standard"; + import { z } from "zod"; + + export const env = arkenv({ + DATABASE_URL: z.string(), + + // Railway system environment variables + RAILWAY_ENVIRONMENT_NAME: z.string().optional(), + RAILWAY_PUBLIC_DOMAIN: z.string().optional(), + RAILWAY_SERVICE_NAME: z.string().optional(), + RAILWAY_GIT_COMMIT_SHA: z.string().optional(), + }); + ``` + + + + ```ts title="env.ts" + import arkenv from "@arkenv/standard"; + import * as v from "valibot"; + + export const env = arkenv({ + DATABASE_URL: v.string(), + + // Railway system environment variables + RAILWAY_ENVIRONMENT_NAME: v.optional(v.string()), + RAILWAY_PUBLIC_DOMAIN: v.optional(v.string()), + RAILWAY_SERVICE_NAME: v.optional(v.string()), + RAILWAY_GIT_COMMIT_SHA: v.optional(v.string()), + }); + ``` + + -You can apply multiple presets to the same schema by running `preset apply` for each provider: +### Render -```bash -npx arkenv preset apply vercel -npx arkenv preset apply railway -``` + + + ```ts title="env.ts" + import arkenv from "@arkenv/core"; + + export const env = arkenv({ + DATABASE_URL: "string", + + // Render system environment variables + RENDER: "string?", + RENDER_SERVICE_ID: "string?", + RENDER_SERVICE_TYPE: "string?", + RENDER_EXTERNAL_URL: "string?", + }); + ``` + + + + ```ts title="env.ts" + import arkenv from "@arkenv/standard"; + import { z } from "zod"; + + export const env = arkenv({ + DATABASE_URL: z.string(), + + // Render system environment variables + RENDER: z.string().optional(), + RENDER_SERVICE_ID: z.string().optional(), + RENDER_SERVICE_TYPE: z.string().optional(), + RENDER_EXTERNAL_URL: z.string().optional(), + }); + ``` + + + + ```ts title="env.ts" + import arkenv from "@arkenv/standard"; + import * as v from "valibot"; + + export const env = arkenv({ + DATABASE_URL: v.string(), + + // Render system environment variables + RENDER: v.optional(v.string()), + RENDER_SERVICE_ID: v.optional(v.string()), + RENDER_SERVICE_TYPE: v.optional(v.string()), + RENDER_EXTERNAL_URL: v.optional(v.string()), + }); + ``` + + -Both blocks coexist independently in your schema file. You can refresh or remove either preset without affecting the other. +### Fly.io -## Customizing, refining, and overriding variables + + + ```ts title="env.ts" + import arkenv from "@arkenv/core"; + + export const env = arkenv({ + DATABASE_URL: "string", + + // Fly.io system environment variables + FLY_APP_NAME: "string?", + FLY_REGION: "string?", + FLY_ALLOC_ID: "string?", + }); + ``` + + + + ```ts title="env.ts" + import arkenv from "@arkenv/standard"; + import { z } from "zod"; + + export const env = arkenv({ + DATABASE_URL: z.string(), + + // Fly.io system environment variables + FLY_APP_NAME: z.string().optional(), + FLY_REGION: z.string().optional(), + FLY_ALLOC_ID: z.string().optional(), + }); + ``` + + + + ```ts title="env.ts" + import arkenv from "@arkenv/standard"; + import * as v from "valibot"; + + export const env = arkenv({ + DATABASE_URL: v.string(), + + // Fly.io system environment variables + FLY_APP_NAME: v.optional(v.string()), + FLY_REGION: v.optional(v.string()), + FLY_ALLOC_ID: v.optional(v.string()), + }); + ``` + + + +## Customizing and refining variables Because ArkEnv gives full code ownership to your repository, you have complete control over how platform variables are validated: -### 1. The "Eject" path (full manual control) - -If you prefer to maintain platform variables entirely by hand, delete the `// @arkenv-preset-start` and `// @arkenv-preset-end` comment lines. +### 1. Adding missing platform variables -The variables immediately become regular, user-owned schema fields. ArkEnv's fail-closed collision engine will protect them from being overwritten if someone runs `preset apply` in the future. - -### 2. Adding missing platform variables - -If a hosting provider introduces a new environment variable before an ArkEnv CLI release includes it, add the variable directly to your schema outside the preset block: +If a hosting provider introduces a new environment variable, add the variable directly to your schema: ```ts title="./env.ts" export const env = arkenv({ + DATABASE_URL: "string", + // Add new or unlisted provider variables directly: VERCEL_NEW_FEATURE: "string?", - - // @arkenv-preset-start vercel - VERCEL: "string?", VERCEL_ENV: "'production' | 'preview' | 'development'?", VERCEL_URL: "string?", - // @arkenv-preset-end vercel }); ``` -### 3. Refining types and transformations +### 2. Refining types and transformations -You can modify field definitions inside or outside the block to add stricter validations, regex constraints, default values, or runtime transformations (such as prefixing URLs with `https://`): +You can modify field definitions to add stricter validations, regex constraints, default values, or runtime transformations (such as prefixing URLs with `https://`): ```ts title="./env.ts" import { type } from "@arkenv/core"; @@ -219,7 +452,7 @@ Every preset field is **optional** (present only when deployed on that provider) ## Next steps - + diff --git a/apps/www/content/docs/guides/ai.mdx b/apps/www/content/docs/guides/ai.mdx index a0751437f..50d058a6f 100644 --- a/apps/www/content/docs/guides/ai.mdx +++ b/apps/www/content/docs/guides/ai.mdx @@ -106,7 +106,7 @@ Add DATABASE_URL to the ArkEnv schema with the right type, update .env.example w **Hosting preset** ```text -Add the Vercel hosting preset with `npx arkenv preset apply vercel --agent` and merge any new system vars into the schema. +Add Vercel hosting system variables to the ArkEnv schema using the provider snippet from `/docs/core-concepts/hosting-presets`. ``` ## Machine-readable documentation diff --git a/apps/www/content/docs/guides/migrating-to-v1.mdx b/apps/www/content/docs/guides/migrating-to-v1.mdx index e8303940a..a4ddc1f26 100644 --- a/apps/www/content/docs/guides/migrating-to-v1.mdx +++ b/apps/www/content/docs/guides/migrating-to-v1.mdx @@ -20,7 +20,7 @@ Importing `arkenv` as a library throws. Swap packages first. | v0 | v1 | Role | | --------------------- | --------------------- | -------------------------------------------------- | | `arkenv` | `@arkenv/core` | Runtime and ArkType engine | -| `@arkenv/cli` | `arkenv` | CLI (`init`, `preset`) | +| `@arkenv/cli` | `arkenv` | CLI (`init`, `check`) | | — | `@arkenv/standard` | Zod, Valibot, and other Standard Schema validators | | `@arkenv/vite-plugin` | `@arkenv/vite-plugin` | Vite plugin (transform mode only) | | `@arkenv/bun-plugin` | `@arkenv/bun-plugin` | Bun plugin (transform mode only) | diff --git a/apps/www/content/docs/reference/check.mdx b/apps/www/content/docs/reference/check.mdx index ab0f51397..5586b9c31 100644 --- a/apps/www/content/docs/reference/check.mdx +++ b/apps/www/content/docs/reference/check.mdx @@ -201,7 +201,7 @@ jobs: - + diff --git a/apps/www/content/docs/reference/index.mdx b/apps/www/content/docs/reference/index.mdx index e0d12a34d..113e76864 100644 --- a/apps/www/content/docs/reference/index.mdx +++ b/apps/www/content/docs/reference/index.mdx @@ -29,8 +29,6 @@ The `arkenv` package is the CLI. Import validation from `@arkenv/core` or - - ## Packages diff --git a/apps/www/content/docs/reference/init.mdx b/apps/www/content/docs/reference/init.mdx index 353d52d94..d5534abf2 100644 --- a/apps/www/content/docs/reference/init.mdx +++ b/apps/www/content/docs/reference/init.mdx @@ -46,7 +46,6 @@ Pre-populate provider system variables. Values: `none`, `vercel`, `--host-preset` and `-H` as aliases. See [Hosting presets](/docs/core-concepts/hosting-presets). -To apply or remove presets later, use [`preset`](/docs/reference/preset). ### `--agent` @@ -116,7 +115,7 @@ Importing `arkenv` as a library throws and points you at `@arkenv/core`. - + diff --git a/apps/www/content/docs/reference/meta.json b/apps/www/content/docs/reference/meta.json index fed9e0811..35778d169 100644 --- a/apps/www/content/docs/reference/meta.json +++ b/apps/www/content/docs/reference/meta.json @@ -10,7 +10,6 @@ "---Commands---", "init", "check", - "preset", "---Packages---", "core", diff --git a/apps/www/content/docs/reference/preset.mdx b/apps/www/content/docs/reference/preset.mdx deleted file mode 100644 index 53d6f0d98..000000000 --- a/apps/www/content/docs/reference/preset.mdx +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: preset -description: Apply, refresh, or remove hosting provider preset blocks in your schema. ---- - -`arkenv preset` writes managed comment blocks into your schema for a -hosting provider. Use it to add platform variables, refresh them when -the provider list changes, or strip a provider when you switch hosts. - -```package-install -npx arkenv preset apply vercel -``` - -```package-install -npx arkenv preset remove vercel -``` - -[Global flags](/docs/reference#global-flags) (`--yes`, `--quiet`, -`--json`, `--agent`, `--help`) apply here too. - -## Usage - -```txt title="Terminal" -arkenv preset [provider] [options] -``` - -`action` is `apply` or `remove` (`rm` is an alias). `apply` injects or -refreshes variables inside managed comment blocks. `remove` deletes that -provider's block and updates `.env.example`. - -`provider` is `vercel`, `netlify`, `cloudflare`, `railway`, `render`, or -`fly`. Omit it to pick interactively. With `--yes` and no provider, the -CLI defaults to `vercel`. - -## Options - -These flags are specific to `preset`. - -### `--file ` - -Point at a schema file. This overrides the `"arkenv"` pointer in -`package.json`. - -```package-install -npx arkenv preset apply vercel --file ./src/config/env.ts -``` - -### `--force` / `-f` - -Bypass the clean git working tree check. - -```package-install -npx arkenv preset apply vercel --force -``` - -### `--yes` / `-y` - -Skip confirmation prompts and accept defaults. - -### `--agent` - -Non-interactive JSON mode for agents. Same macro as the -[global `--agent` flag](/docs/reference#agent): `--yes --quiet --json`. - -## Behavior - -The CLI detects the framework public prefix -(`NEXT_PUBLIC_`, `NUXT_PUBLIC_`, `VITE_`), and the validator dialect -(ArkType, Zod, Valibot). Then it: - -1. Reads the `"arkenv"` object in `package.json` to find schema files. -2. Refuses a dirty git tree unless you pass `--force`. -3. Rejects collisions with fields you defined outside managed blocks. -4. Inserts, refreshes, or removes `// @arkenv-preset-start` / - `// @arkenv-preset-end` blocks. -5. Syncs `.env.example` for added or removed keys and leaves shared keys - in place. - -See [Hosting presets](/docs/core-concepts/hosting-presets) -for the field lists. - -## Next steps - - - - - - diff --git a/apps/www/lib/normalize-package-manager-command.test.ts b/apps/www/lib/normalize-package-manager-command.test.ts index 81137517b..8a42bc07b 100644 --- a/apps/www/lib/normalize-package-manager-command.test.ts +++ b/apps/www/lib/normalize-package-manager-command.test.ts @@ -123,10 +123,10 @@ describe("normalizePackageManagerCommand", () => { "Add ArkEnv to this repo. Run `npx arkenv@alpha init --agent`, parse the JSON on stdout, and only retry with flags from `retryWith` if a refusal is safe to bypass.", ); - const presetPrompt = - "Add the Vercel hosting preset with `npx arkenv preset apply vercel --agent` and merge any new system vars into the schema."; - expect(normalizePackageManagerCommand(presetPrompt)).toBe( - "Add the Vercel hosting preset with `npx arkenv@alpha preset apply vercel --agent` and merge any new system vars into the schema.", + const initPresetPrompt = + "Bootstrap the project with Vercel preset using `npx arkenv init --preset vercel --agent`."; + expect(normalizePackageManagerCommand(initPresetPrompt)).toBe( + "Bootstrap the project with Vercel preset using `npx arkenv@alpha init --preset vercel --agent`.", ); // GA mode (empty tag) diff --git a/apps/www/next.config.ts b/apps/www/next.config.ts index e6a772a1f..73b837605 100644 --- a/apps/www/next.config.ts +++ b/apps/www/next.config.ts @@ -435,7 +435,12 @@ const config = { }, { source: "/docs/reference/add-host", - destination: "/docs/reference/preset", + destination: "/docs/core-concepts/hosting-presets", + permanent: true, + }, + { + source: "/docs/reference/preset", + destination: "/docs/core-concepts/hosting-presets", permanent: true, }, { diff --git a/packages/arkenv/ARCHITECTURE.md b/packages/arkenv/ARCHITECTURE.md index 8d84db741..b6b77f171 100644 --- a/packages/arkenv/ARCHITECTURE.md +++ b/packages/arkenv/ARCHITECTURE.md @@ -23,8 +23,7 @@ src/ ├── features/ # Pure Business Domains (Headless) │ ├── scaffold/ # Generation engine (Planner, Executor) │ ├── schema-loader/ # Inspect env.ts schemas without validating env -│ ├── example/ # Merge declared keys into .env.example -│ └── config-mutation/ # AST-based configuration manipulation +│ └── example/ # Merge declared keys into .env.example │ ├── adapters/ # Driven Adapters (Infrastructure) │ ├── node-workspace.adapter.ts # Concrete File System & Process logic @@ -51,7 +50,7 @@ The dependency direction always flows **inward** toward the ports or **outward** The architecture is specifically designed to support AI agents and headless environments via the `--agent` and `--json` flags. - When `--agent` is passed, the `composition.ts` root injects a `JsonReporter` into the `LoggerPort`. -- Because the `scaffold` and `config-mutation` features are headless, they continue to function exactly the same. +- Because the `scaffold` features are headless, they continue to function exactly the same. - The `init` command skips interactive `ui/prompts` and passes default/provided options directly to the `features`. - No interactive prompt will ever hang a headless process. diff --git a/packages/arkenv/src/adapters/node-workspace/utils/bootstrappers.test.ts b/packages/arkenv/src/adapters/node-workspace/utils/bootstrappers.test.ts new file mode 100644 index 000000000..e558b31df --- /dev/null +++ b/packages/arkenv/src/adapters/node-workspace/utils/bootstrappers.test.ts @@ -0,0 +1,327 @@ +import dedent from "dedent"; +import { describe, expect, it } from "vitest"; +import { + transformNextjsConfig, + transformNuxtConfig, + transformViteConfig, +} from "./bootstrappers"; + +describe("bootstrappers", () => { + describe("transformViteConfig", () => { + it("injects plugin into a standard vite.config.ts", async () => { + const initialContent = dedent` + import { defineConfig } from "vite" + export default defineConfig({ + plugins: [] + }) + `; + + const result = transformViteConfig({ code: initialContent }); + expect(result.success).toBe(true); + + expect(result.code).toContain( + 'import arkenvVitePlugin from "@arkenv/vite-plugin"', + ); + expect(result.code).toContain("arkenvVitePlugin()"); + }); + + it("injects plugin into a simple object export", async () => { + const initialContent = dedent` + export default { + plugins: [] + } + `; + + const result = transformViteConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.code).toContain("arkenvVitePlugin()"); + }); + + it("handles missing plugins array", async () => { + const initialContent = dedent` + export default { + build: {} + } + `; + + const result = transformViteConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.code).toContain("plugins: ["); + expect(result.code).toContain("arkenvVitePlugin()"); + }); + + it("preserves space indentation format", () => { + const initialContent = + 'import { defineConfig } from "vite";\n\nexport default defineConfig({\n plugins: [],\n});\n'; + const result = transformViteConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.code).toContain(" plugins: [arkenvVitePlugin()]"); + expect(result.code?.endsWith("\n")).toBe(true); + }); + + it("preserves tab indentation format", () => { + const initialContent = + 'import { defineConfig } from "vite";\n\nexport default defineConfig({\n\tplugins: [],\n});\n'; + const result = transformViteConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.code).toContain("\tplugins: [arkenvVitePlugin()]"); + }); + + it("preserves absence of trailing newline", () => { + const initialContent = "export default { plugins: [] }"; + const result = transformViteConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.code?.endsWith("\n")).toBe(false); + }); + + it("refuses defineConfig callback form with an actionable message", () => { + const initialContent = + 'import { defineConfig } from "vite";\nexport default defineConfig((env) => ({\n plugins: [],\n}));'; + const result = transformViteConfig({ code: initialContent }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain( + "The 'defineConfig' callback form is currently not supported", + ); + } + }); + + it("fails when default export is not an object", () => { + const initialContent = "export default 123;"; + const result = transformViteConfig({ code: initialContent }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain( + "Could not find default export object in Vite config", + ); + } + }); + + it("fails when plugins property is not an array", () => { + const initialContent = 'export default { plugins: "not-an-array" };'; + const result = transformViteConfig({ code: initialContent }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain( + "The 'plugins' property in your Vite config is not an array", + ); + } + }); + + it("fails gracefully on invalid syntax", () => { + const result = transformViteConfig({ code: "const invalid = {" }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Failed to parse Vite config"); + } + }); + + it("does not duplicate plugin if already exists and returns updated: false", async () => { + const initialContent = dedent` + import arkenvVitePlugin from "@arkenv/vite-plugin" + export default { + plugins: [arkenvVitePlugin()] + } + `; + + const result = transformViteConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.updated).toBe(false); + }); + + it("returns updated: true when plugin is injected", async () => { + const initialContent = dedent` + export default { + plugins: [] + } + `; + + const result = transformViteConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.updated).toBe(true); + }); + }); + + describe("transformNextjsConfig", () => { + it("wraps default export with withArkEnv", async () => { + const initialContent = dedent` + const nextConfig = { + reactStrictMode: true + } + export default nextConfig + `; + + const result = transformNextjsConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.code).toContain( + 'import { withArkEnv } from "@arkenv/nextjs/config"', + ); + expect(result.code).toContain("export default withArkEnv(nextConfig)"); + }); + + it("supports disableCodegen option", async () => { + const initialContent = dedent` + export default { + reactStrictMode: true + } + `; + + const result = transformNextjsConfig({ + code: initialContent, + disableCodegen: true, + }); + expect(result.success).toBe(true); + expect(result.code).toContain( + 'import { withArkEnv } from "@arkenv/nextjs/config"', + ); + expect(result.code).toContain("withArkEnv({"); + expect(result.code).toContain("codegen: false"); + }); + + it("refuses CommonJS module.exports", () => { + const initialContent = "module.exports = { reactStrictMode: true };"; + const result = transformNextjsConfig({ code: initialContent }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain( + "CommonJS is not supported for automatic mutation", + ); + } + }); + + it("fails when missing default export", () => { + const initialContent = "export const foo = 123;"; + const result = transformNextjsConfig({ code: initialContent }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain( + "Could not find default export in Next.js config", + ); + } + }); + + it("does not duplicate if already wrapped with withArkEnv AST", async () => { + const initialContent = dedent` + import { withArkEnv } from "@arkenv/nextjs/config" + export default withArkEnv({ + reactStrictMode: true + }) + `; + + const result = transformNextjsConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.updated).toBe(false); + }); + + it("does not duplicate if withArkEnv is referenced inline or elsewhere", () => { + const initialContent = + "const wrapped = withArkEnv(config);\nexport default wrapped;"; + const result = transformNextjsConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.updated).toBe(false); + }); + + it("preserves trailing newline", () => { + const initialContent = "export default {};\n"; + const result = transformNextjsConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.code?.endsWith("\n")).toBe(true); + }); + + it("preserves absence of trailing newline", () => { + const initialContent = "export default {}"; + const result = transformNextjsConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.code?.endsWith("\n")).toBe(false); + }); + + it("fails gracefully on invalid syntax", () => { + const result = transformNextjsConfig({ code: "export default {" }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Failed to parse Next.js config"); + } + }); + }); + + describe("transformNuxtConfig", () => { + it("adds nuxt module to defineNuxtConfig", async () => { + const initialContent = dedent` + export default defineNuxtConfig({ + modules: [] + }) + `; + + const result = transformNuxtConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.code).toContain('"@arkenv/nuxt/module"'); + }); + + it("creates modules array if missing in defineNuxtConfig", () => { + const initialContent = + "export default defineNuxtConfig({\n ssr: true,\n});\n"; + const result = transformNuxtConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.code).toContain('"@arkenv/nuxt/module"'); + }); + + it("adds nuxt module to simple object export", () => { + const initialContent = "export default { ssr: true };"; + const result = transformNuxtConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.code).toContain('"@arkenv/nuxt/module"'); + }); + + it("does not duplicate nuxt module if already present", async () => { + const initialContent = dedent` + export default defineNuxtConfig({ + modules: ["@arkenv/nuxt/module"] + }) + `; + + const result = transformNuxtConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.updated).toBe(false); + }); + + it("fails when modules is not an array", () => { + const initialContent = + 'export default defineNuxtConfig({ modules: "invalid" });'; + const result = transformNuxtConfig({ code: initialContent }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain( + "The 'modules' property in your Nuxt config is not an array", + ); + } + }); + + it("fails when default export is not an object", () => { + const initialContent = "export default 42;"; + const result = transformNuxtConfig({ code: initialContent }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain( + "Could not find default export object in Nuxt config", + ); + } + }); + + it("fails gracefully on invalid syntax", () => { + const result = transformNuxtConfig({ + code: "export default defineNuxtConfig({", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Failed to parse Nuxt config"); + } + }); + + it("preserves trailing newline", () => { + const initialContent = "export default defineNuxtConfig({});\n"; + const result = transformNuxtConfig({ code: initialContent }); + expect(result.success).toBe(true); + expect(result.code?.endsWith("\n")).toBe(true); + }); + }); +}); diff --git a/packages/arkenv/src/adapters/node-workspace/utils/bootstrappers.ts b/packages/arkenv/src/adapters/node-workspace/utils/bootstrappers.ts index 1e6690bf2..7c9bd1212 100644 --- a/packages/arkenv/src/adapters/node-workspace/utils/bootstrappers.ts +++ b/packages/arkenv/src/adapters/node-workspace/utils/bootstrappers.ts @@ -1,15 +1,327 @@ import fsp from "node:fs/promises"; import path from "node:path"; import dedent from "dedent"; +import { + builders, + detectCodeFormat, + generateCode, + parseModule, +} from "magicast"; import pc from "picocolors"; import { code } from "@/cli/ui/visuals"; -import { - transformNextjsConfig, - transformNuxtConfig, - transformViteConfig, -} from "@/features/config-mutation"; import type { BootstrapResult } from "@/shared/ports"; +/** + * Input for transforming a framework configuration file. + */ +export type MutationInput = { + code: string; + disableCodegen?: boolean | undefined; +}; + +/** + * Normalizes named import spacing in generated code. + * magicast produces `import {Foo}`; this ensures `import { Foo }`. + */ +function normalizeImportSpacing(code: string): string { + return code.replace( + /import\s*\{([^\n}]*)\}\s*from/g, + (_match, p1) => `import { ${p1.trim()} } from`, + ); +} + +/** + * Preserves the trailing newline of the original file if present. + * magicast strips trailing newlines; this restores them. + */ +function preserveTrailingNewline(code: string, originalCode: string): string { + return originalCode.endsWith("\n") && !code.endsWith("\n") + ? `${code}\n` + : code; +} + +/** + * Transforms a Vite configuration file by injecting the ArkEnv Vite plugin. + * + * @param input The configuration code and optional import path. + * @returns The result of the bootstrap operation, potentially including the updated code. + */ +export function transformViteConfig( + input: MutationInput, +): BootstrapResult & { code?: string } { + try { + const mod = parseModule(input.code); + const initialCode = input.code; + + // 1. Find the plugins array + let config = mod.exports.default; + + // Handle defineConfig({...}) wrapper + if ( + config && + typeof config === "object" && + "$type" in config && + config.$type === "function-call" + ) { + const call = config as { $callee?: string; $args?: any[] }; + const callee = call.$callee || JSON.stringify(config); + if (callee === "defineConfig") { + const rawArg = (call as { $ast?: { arguments?: { type: string }[] } }) + .$ast?.arguments?.[0]; + if ( + rawArg?.type === "ArrowFunctionExpression" || + rawArg?.type === "FunctionExpression" + ) { + return { + success: false, + updated: false, + error: + "The 'defineConfig' callback form is currently not supported for automatic mutation. Please add the plugin manually.", + }; + } + const arg = call.$args?.[0]; + // Guard against defineConfig((env) => ({...})) callback form + if ( + arg && + typeof arg === "object" && + "$type" in arg && + (arg.$type === "arrow-function-expression" || + arg.$type === "function-expression") + ) { + return { + success: false, + updated: false, + error: + "The 'defineConfig' callback form is currently not supported for automatic mutation. Please add the plugin manually.", + }; + } + config = arg; + } + } + + if ( + !config || + typeof config !== "object" || + (typeof config === "object" && "$type" in config) + ) { + return { + success: false, + updated: false, + error: "Could not find default export object in Vite config", + }; + } + + if (!config.plugins) { + config.plugins = []; + } + + if (Array.isArray(config.plugins)) { + // Check if already exists using a word-boundary regex to avoid false positives + const hasPlugin = /\barkenv(?:Vite)?Plugin\b/.test(initialCode); + + if (!hasPlugin) { + // Add imports + mod.imports.$add({ + from: "@arkenv/vite-plugin", + local: "arkenvVitePlugin", + imported: "default", + }); + + config.plugins.push("__ARK_PLUGIN_PLACEHOLDER__"); + } else { + // Already has plugin, nothing to do + return { success: true, updated: false }; + } + } else { + return { + success: false, + updated: false, + error: "The 'plugins' property in your Vite config is not an array.", + }; + } + + let code = generateCode(mod, { + format: detectCodeFormat(initialCode), + }).code; + const pluginCall = "arkenvVitePlugin()"; + code = code.replace(/['"]__ARK_PLUGIN_PLACEHOLDER__['"]/g, pluginCall); + code = normalizeImportSpacing(code); + code = preserveTrailingNewline(code, initialCode); + + return { success: true, updated: true, code }; + } catch (e: unknown) { + const error = e instanceof Error ? e.message : String(e); + return { + success: false, + updated: false, + error: `Failed to parse Vite config: ${error}`, + }; + } +} + +/** + * Transform a Next.js configuration file by wrapping the default export with `withArkEnv`. + * + * @param input The configuration code and optional import path + * @returns The result of the bootstrap operation, potentially including the updated code + */ +export function transformNextjsConfig( + input: MutationInput, +): BootstrapResult & { code?: string } { + try { + const initialCode = input.code; + + // Check for CommonJS - can't auto-mutate + if (/module\.exports\b/.test(initialCode)) { + return { + success: false, + updated: false, + error: + "CommonJS is not supported for automatic mutation. Please wrap your config with `withArkEnv` manually.", + }; + } + + const mod = parseModule(initialCode); + + // Verify there's a default export + if (!mod.exports.default) { + return { + success: false, + updated: false, + error: "Could not find default export in Next.js config", + }; + } + + // Check if already wrapped with withArkEnv using the AST + if ( + typeof mod.exports.default === "object" && + "$type" in (mod.exports.default as object) && + (mod.exports.default as { $type?: string }).$type === "function-call" && + (mod.exports.default as { $callee?: string }).$callee === "withArkEnv" + ) { + return { success: true, updated: false }; + } + + // Also check via regex for cases where withArkEnv is used inline + if (/\bwithArkEnv\b/.test(initialCode)) { + return { success: true, updated: false }; + } + + // Add import + mod.imports.$add({ + from: "@arkenv/nextjs/config", + imported: "withArkEnv", + }); + + // Wrap the default export with withArkEnv(...) using the AST + if (input.disableCodegen) { + mod.exports.default = builders.functionCall( + "withArkEnv", + mod.exports.default, + { codegen: false }, + ); + } else { + mod.exports.default = builders.functionCall( + "withArkEnv", + mod.exports.default, + ); + } + + let code = generateCode(mod, { + format: detectCodeFormat(initialCode), + }).code; + code = normalizeImportSpacing(code); + code = preserveTrailingNewline(code, initialCode); + + return { success: true, updated: true, code }; + } catch (e: unknown) { + const error = e instanceof Error ? e.message : String(e); + return { + success: false, + updated: false, + error: `Failed to parse Next.js config: ${error}`, + }; + } +} + +/** + * Transform a Nuxt configuration file by adding `@arkenv/nuxt/module` to its modules. + * + * @param input The configuration code and optional import path + * @returns The result of the bootstrap operation, potentially including the updated code + */ +export function transformNuxtConfig( + input: MutationInput, +): BootstrapResult & { code?: string } { + try { + const initialCode = input.code; + const mod = parseModule(initialCode); + + let config = mod.exports.default; + + // Handle defineNuxtConfig({...}) wrapper + if ( + config && + typeof config === "object" && + "$type" in config && + config.$type === "function-call" + ) { + const call = config as { $callee?: string; $args?: any[] }; + const callee = call.$callee || JSON.stringify(config); + if (callee === "defineNuxtConfig" && call.$args) { + config = call.$args[0]; + } + } + + if ( + !config || + typeof config !== "object" || + (typeof config === "object" && "$type" in config) + ) { + return { + success: false, + updated: false, + error: "Could not find default export object in Nuxt config", + }; + } + + if (!config.modules) { + config.modules = []; + } + + if (Array.isArray(config.modules)) { + const hasModule = config.modules.includes("@arkenv/nuxt/module"); + + if (!hasModule) { + config.modules.push("@arkenv/nuxt/module"); + } else { + return { success: true, updated: false }; + } + } else { + return { + success: false, + updated: false, + error: "The 'modules' property in your Nuxt config is not an array.", + }; + } + + let code = generateCode(mod, { + format: detectCodeFormat(initialCode), + }).code; + code = normalizeImportSpacing(code); + code = preserveTrailingNewline(code, initialCode); + + return { success: true, updated: true, code }; + } catch (e: unknown) { + const error = e instanceof Error ? e.message : String(e); + return { + success: false, + updated: false, + error: `Failed to parse Nuxt config: ${error}`, + }; + } +} + export async function findViteConfig( cwd = process.cwd(), ): Promise { @@ -185,13 +497,12 @@ export async function bootstrapViteConfig( writeFile(path: string, content: string): Promise; }, filePath: string, - importPath: string, + _importPath?: string, ): Promise { try { const configCode = await workspace.readFile(filePath); const result = transformViteConfig({ code: configCode, - envImportPath: importPath, }); if (result.success && result.updated && result.code) { diff --git a/packages/arkenv/src/bin.ts b/packages/arkenv/src/bin.ts index 5f5451cf6..e0bfbbe8a 100644 --- a/packages/arkenv/src/bin.ts +++ b/packages/arkenv/src/bin.ts @@ -20,8 +20,9 @@ async function main() { } } - const { cli, logger, initUseCase, presetUseCase, helpUseCase, checkUseCase } = - compose(process.argv); + const { cli, logger, initUseCase, helpUseCase, checkUseCase } = compose( + process.argv, + ); globalLogger = logger; setupGracefulShutdown(logger, cli.command); @@ -41,7 +42,6 @@ async function main() { const commands = { init: () => initUseCase.execute(shake(cli.initInput)), - preset: () => presetUseCase.execute(cli.presetInput), check: () => checkUseCase.execute(cli.checkInput), } as const; diff --git a/packages/arkenv/src/cli/cli.test.ts b/packages/arkenv/src/cli/cli.test.ts index d1b21b308..5aaf0c7bd 100644 --- a/packages/arkenv/src/cli/cli.test.ts +++ b/packages/arkenv/src/cli/cli.test.ts @@ -307,87 +307,6 @@ describe("CLI parser", () => { const invalid = new CLI(["node", "arkenv", "init", "-H", "vercle"]); expect(invalid.validationError).toBe("Invalid host preset: vercle"); }); - - describe("preset command", () => { - it("should parse valid preset apply vercel command", () => { - const cli = new CLI(["node", "arkenv", "preset", "apply", "vercel"]); - expect(cli.command).toBe("preset"); - expect(cli.presetInput.action).toBe("apply"); - expect(cli.presetInput.provider).toBe("vercel"); - expect(cli.validationError).toBeUndefined(); - }); - - it("should parse valid preset remove netlify command", () => { - const cli = new CLI(["node", "arkenv", "preset", "remove", "netlify"]); - expect(cli.command).toBe("preset"); - expect(cli.presetInput.action).toBe("remove"); - expect(cli.presetInput.provider).toBe("netlify"); - expect(cli.validationError).toBeUndefined(); - }); - - it("should parse valid preset rm alias", () => { - const cli = new CLI(["node", "arkenv", "preset", "rm", "cloudflare"]); - expect(cli.command).toBe("preset"); - expect(cli.presetInput.action).toBe("remove"); - expect(cli.presetInput.provider).toBe("cloudflare"); - expect(cli.validationError).toBeUndefined(); - }); - - it("should parse preset apply with omitted provider", () => { - const cli = new CLI(["node", "arkenv", "preset", "apply"]); - expect(cli.command).toBe("preset"); - expect(cli.presetInput.action).toBe("apply"); - expect(cli.presetInput.provider).toBeUndefined(); - expect(cli.validationError).toBeUndefined(); - }); - - it("should parse flags in presetInput", () => { - const cli = new CLI([ - "node", - "arkenv", - "preset", - "apply", - "vercel", - "--force", - "--yes", - "--file", - "./custom-env.ts", - ]); - expect(cli.command).toBe("preset"); - expect(cli.presetInput.action).toBe("apply"); - expect(cli.presetInput.provider).toBe("vercel"); - expect(cli.presetInput.isForce).toBe(true); - expect(cli.presetInput.isYes).toBe(true); - expect(cli.presetInput.file).toBe("./custom-env.ts"); - }); - - it("should reject bare preset without a subcommand", () => { - const cli = new CLI(["node", "arkenv", "preset"]); - expect(cli.validationError).toBe("Missing subcommand"); - }); - - it("should reject unknown preset action", () => { - const cli = new CLI(["node", "arkenv", "preset", "invalid"]); - expect(cli.validationError).toBe("Unknown preset action: invalid"); - }); - - it("should reject invalid provider in preset apply", () => { - const cli = new CLI(["node", "arkenv", "preset", "apply", "vercle"]); - expect(cli.validationError).toBe("Invalid host preset: vercle"); - }); - - it("should reject extra positional arguments", () => { - const cli = new CLI([ - "node", - "arkenv", - "preset", - "apply", - "vercel", - "extra", - ]); - expect(cli.validationError).toBe("Unknown argument: extra"); - }); - }); }); describe("check command", () => { diff --git a/packages/arkenv/src/cli/cli.ts b/packages/arkenv/src/cli/cli.ts index c20f9ccc2..c84d2ee59 100644 --- a/packages/arkenv/src/cli/cli.ts +++ b/packages/arkenv/src/cli/cli.ts @@ -1,12 +1,7 @@ import { Logger } from "@/adapters"; -import { - type HostPreset, - isHostPreset, - isHostProvider, -} from "@/features/scaffold/presets"; +import { type HostPreset, isHostPreset } from "@/features/scaffold/presets"; import type { CheckInput } from "./commands/check"; import type { InitInput } from "./commands/init"; -import type { PresetInput } from "./commands/preset"; const FLAG_CONFIG = { isYes: { long: "--yes", short: "-y", kind: "boolean" }, @@ -22,7 +17,6 @@ const FLAG_CONFIG = { noCodegen: { long: "--no-codegen", short: "", kind: "boolean" }, preset: { long: "--preset", short: "-P", kind: "value" }, hostPreset: { long: "--host-preset", short: "-H", kind: "value" }, - file: { long: "--file", short: "", kind: "value" }, schema: { long: "--schema", short: "-s", kind: "value" }, envFile: { long: "--env-file", short: "", kind: "value" }, verifyExample: { @@ -141,28 +135,7 @@ export class CLI { this.positionalArgs = positionalArgs; if (!this.validationError) { - if (this.command === "preset") { - if (positionalArgs.length === 0) { - this.validationError = "Missing subcommand"; - } else { - const action = positionalArgs[0]; - if ( - action !== "apply" && - action !== "remove" && - action !== "rm" && - action !== "add" - ) { - this.validationError = `Unknown preset action: ${action}`; - } else if (positionalArgs.length > 2) { - this.validationError = `Unknown argument: ${positionalArgs[2]}`; - } else { - const provider = positionalArgs[1]; - if (provider !== undefined && !isHostProvider(provider)) { - this.validationError = `Invalid host preset: ${provider}`; - } - } - } - } else if (this.command === "check") { + if (this.command === "check") { if (positionalArgs.length > 0) { this.validationError = `Unknown argument: ${positionalArgs[0]}`; } @@ -217,14 +190,9 @@ export class CLI { return this.hasFlag("noCodegen"); } - get file(): string | undefined { - const flag = FLAG_CONFIG.file; - return this.getFlagValue(flag.long, flag.short); - } - get schema(): string | undefined { const flag = FLAG_CONFIG.schema; - return this.getFlagValue(flag.long, flag.short) ?? this.file; + return this.getFlagValue(flag.long, flag.short); } get envFiles(): string[] { @@ -282,21 +250,6 @@ export class CLI { return input; } - get presetInput(): PresetInput { - const rawAction = this.positionalArgs[0]; - const action = - rawAction === "remove" || rawAction === "rm" ? "remove" : "apply"; - const provider = this.positionalArgs[1]; - const file = this.file; - return { - action, - ...(provider && isHostProvider(provider) ? { provider } : {}), - ...(file !== undefined ? { file } : {}), - isForce: this.isForce, - isYes: this.isYes, - }; - } - get checkInput(): CheckInput { return { ...(this.schema !== undefined diff --git a/packages/arkenv/src/cli/commands/help.test.ts b/packages/arkenv/src/cli/commands/help.test.ts index f19007905..41089da84 100644 --- a/packages/arkenv/src/cli/commands/help.test.ts +++ b/packages/arkenv/src/cli/commands/help.test.ts @@ -26,33 +26,20 @@ describe("HelpUseCase", () => { ); expect(initCommandLog).toBeDefined(); expect(initCommandLog).toBe( - " arkenv init [project-name] Set up ArkEnv in your project", + " arkenv init [project-name] Set up ArkEnv in your project", ); const checkCommandLog = logs.find((l) => l.includes("arkenv check")); expect(checkCommandLog).toBeDefined(); expect(checkCommandLog).toBe( - " arkenv check Validate the environment against the schema", + " arkenv check Validate the environment against the schema", ); const exampleCommandLog = logs.find((l) => l.includes("arkenv example")); expect(exampleCommandLog).toBeUndefined(); - const presetApplyCommandLog = logs.find((l) => - l.includes("arkenv preset apply [provider]"), - ); - expect(presetApplyCommandLog).toBeDefined(); - expect(presetApplyCommandLog).toBe( - " arkenv preset apply [provider] Apply or refresh hosting provider preset (vercel, netlify, cloudflare, railway, render, fly)", - ); - - const presetRemoveCommandLog = logs.find((l) => - l.includes("arkenv preset remove [provider]"), - ); - expect(presetRemoveCommandLog).toBeDefined(); - expect(presetRemoveCommandLog).toBe( - " arkenv preset remove [provider] Remove hosting provider preset from schema", - ); + const presetCommandLog = logs.find((l) => l.includes("arkenv preset")); + expect(presetCommandLog).toBeUndefined(); const globalHeaderIndex = logs.findIndex((l) => l.includes(pc.bold("Global options:")), @@ -124,11 +111,12 @@ describe("HelpUseCase", () => { const checkHeaderIndex = logs.findIndex((l) => l.includes(pc.bold("check options:")), ); + expect(checkHeaderIndex).toBeGreaterThan(initHeaderIndex); + const presetHeaderIndex = logs.findIndex((l) => l.includes(pc.bold("preset options:")), ); - expect(checkHeaderIndex).toBeGreaterThan(initHeaderIndex); - expect(presetHeaderIndex).toBeGreaterThan(checkHeaderIndex); + expect(presetHeaderIndex).toBe(-1); const verifyExampleOptionLog = logs.find((l) => l.includes("--verify-example [file]"), @@ -137,8 +125,5 @@ describe("HelpUseCase", () => { expect(logs.indexOf(verifyExampleOptionLog as string)).toBeGreaterThan( checkHeaderIndex, ); - expect(logs.indexOf(verifyExampleOptionLog as string)).toBeLessThan( - presetHeaderIndex, - ); }); }); diff --git a/packages/arkenv/src/cli/commands/help.ts b/packages/arkenv/src/cli/commands/help.ts index adfed9aad..58f1fc2ab 100644 --- a/packages/arkenv/src/cli/commands/help.ts +++ b/packages/arkenv/src/cli/commands/help.ts @@ -42,15 +42,6 @@ export class HelpUseCase { left: "arkenv check", right: "Validate the environment against the schema", }, - { - left: "arkenv preset apply [provider]", - right: - "Apply or refresh hosting provider preset (vercel, netlify, cloudflare, railway, render, fly)", - }, - { - left: "arkenv preset remove [provider]", - right: "Remove hosting provider preset from schema", - }, ]; const globalOptions: HelpItem[] = [ @@ -116,17 +107,6 @@ export class HelpUseCase { }, ]; - const presetOptions: HelpItem[] = [ - { - left: "--file ", - right: "Path to schema file (overrides package.json arkenv pointer)", - }, - { - left: "--force, -f", - right: "Bypass dirty git working tree check", - }, - ]; - this.logger.log(`ArkEnv CLI v${version}`); this.logger.log(`\n${pc.bold("Usage:")}`); for (const line of formatColumns(commands)) { @@ -144,9 +124,5 @@ export class HelpUseCase { for (const line of formatColumns(checkOptions)) { this.logger.log(line); } - this.logger.log(`\n${pc.bold("preset options:")}`); - for (const line of formatColumns(presetOptions)) { - this.logger.log(line); - } } } diff --git a/packages/arkenv/src/cli/commands/index.ts b/packages/arkenv/src/cli/commands/index.ts index bbb917767..99073d99d 100644 --- a/packages/arkenv/src/cli/commands/index.ts +++ b/packages/arkenv/src/cli/commands/index.ts @@ -1,4 +1,3 @@ export * from "./check"; export * from "./help"; export * from "./init"; -export * from "./preset"; diff --git a/packages/arkenv/src/cli/commands/preset.test.ts b/packages/arkenv/src/cli/commands/preset.test.ts deleted file mode 100644 index 3cbbe4f78..000000000 --- a/packages/arkenv/src/cli/commands/preset.test.ts +++ /dev/null @@ -1,459 +0,0 @@ -import path from "node:path"; -import dedent from "dedent"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ERROR_CODES } from "@/shared/errors"; -import type { - LoggerPort, - ProjectScannerPort, - PromptPort, - WorkspacePort, -} from "@/shared/ports"; -import { detectValidator, PresetUseCase } from "./preset"; - -describe("PresetUseCase", () => { - let logger: LoggerPort; - let workspace: WorkspacePort; - let prompt: PromptPort; - let scanner: ProjectScannerPort; - let useCase: PresetUseCase; - - beforeEach(() => { - logger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - fatal: vi.fn(), - step: vi.fn(), - success: vi.fn(), - cancel: vi.fn(), - note: vi.fn(), - finish: vi.fn(), - log: vi.fn(), - refuse: vi.fn(), - spinner: vi.fn().mockReturnValue({ - start: vi.fn(), - stop: vi.fn(), - }), - interactiveStdout: vi.fn(), - } as unknown as LoggerPort; - - workspace = { - exists: vi.fn().mockImplementation(async (p: string) => { - if (p.endsWith("client.ts") || p.endsWith("server.ts")) { - return false; - } - return true; - }), - readFile: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), - execute: vi.fn(), - appendMissingEnvExampleKeys: vi.fn().mockResolvedValue(true), - removeEnvExampleKeys: vi.fn().mockResolvedValue(true), - } as unknown as WorkspacePort; - - prompt = { - confirm: vi.fn(), - runWizard: vi.fn(), - select: vi.fn(), - } as unknown as PromptPort; - - scanner = { - hasPackageJson: vi.fn().mockResolvedValue(true), - isEmptyDirectory: vi.fn().mockResolvedValue(false), - checkRequirements: vi.fn().mockResolvedValue([]), - checkTsConfig: vi - .fn() - .mockResolvedValue({ status: "strict", parsed: null }), - detectFramework: vi.fn().mockResolvedValue("vanilla"), - suggestDefaultEnvPath: vi.fn().mockResolvedValue("./env.ts"), - getEnvExampleKeys: vi.fn().mockResolvedValue(null), - detectPackageManager: vi.fn().mockResolvedValue("pnpm"), - hasSkill: vi.fn().mockResolvedValue(false), - checkGitStatus: vi.fn().mockResolvedValue({ status: "clean" }), - findPackageJson: vi.fn().mockResolvedValue("/root/package.json"), - readArkenvConfig: vi.fn().mockResolvedValue(null), - } as unknown as ProjectScannerPort; - - useCase = new PresetUseCase(logger, workspace, prompt, scanner); - }); - - describe("git working tree gating", () => { - it("refuses execution when git working tree is dirty and --force is not passed", async () => { - vi.mocked(scanner.checkGitStatus).mockResolvedValue({ status: "dirty" }); - - const result = await useCase.execute({ - action: "apply", - provider: "vercel", - }); - - expect(result).toBe(false); - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Git working tree is not clean"), - ); - expect(logger.refuse).toHaveBeenCalledWith( - expect.objectContaining({ - code: ERROR_CODES.GIT_TREE_DIRTY, - message: "Git working tree is not clean.", - retryWith: ["--force"], - }), - "preset", - ); - expect(workspace.writeFile).not.toHaveBeenCalled(); - }); - - it("proceeds when git working tree is dirty if isForce is true", async () => { - vi.mocked(scanner.checkGitStatus).mockResolvedValue({ status: "dirty" }); - vi.mocked(workspace.exists).mockImplementation(async (p: string) => - p.endsWith("env.ts"), - ); - vi.mocked(workspace.readFile).mockResolvedValue(dedent` - import { type } from "@arkenv/core"; - - export const Env = type({ - DATABASE_URL: "string", - }); - `); - - const result = await useCase.execute({ - action: "apply", - provider: "vercel", - isForce: true, - }); - - expect(result).toBe(true); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining("continuing due to --force flag"), - ); - expect(workspace.writeFile).toHaveBeenCalled(); - }); - - it("proceeds when not_a_repo", async () => { - vi.mocked(scanner.checkGitStatus).mockResolvedValue({ - status: "not_a_repo", - }); - vi.mocked(workspace.exists).mockImplementation(async (p: string) => - p.endsWith("env.ts"), - ); - vi.mocked(workspace.readFile).mockResolvedValue(dedent` - import { type } from "@arkenv/core"; - - export const Env = type({ - DATABASE_URL: "string", - }); - `); - - const result = await useCase.execute({ - action: "apply", - provider: "vercel", - }); - - expect(result).toBe(true); - expect(workspace.writeFile).toHaveBeenCalled(); - }); - }); - - describe("preset apply", () => { - it("applies managed preset block with markers to flat schema", async () => { - vi.mocked(workspace.exists).mockImplementation(async (p: string) => - p.endsWith("env.ts"), - ); - vi.mocked(workspace.readFile).mockResolvedValue(dedent` - import { type } from "@arkenv/core"; - - export const Env = type({ - DATABASE_URL: "string", - }); - `); - - const result = await useCase.execute({ - action: "apply", - provider: "vercel", - }); - - expect(result).toBe(true); - expect(workspace.writeFile).toHaveBeenCalledWith( - expect.stringContaining("env.ts"), - expect.stringContaining("// @arkenv-preset-start vercel"), - ); - expect(workspace.writeFile).toHaveBeenCalledWith( - expect.stringContaining("env.ts"), - expect.stringContaining('VERCEL: "string?"'), - ); - expect(workspace.writeFile).toHaveBeenCalledWith( - expect.stringContaining("env.ts"), - expect.stringContaining("// @arkenv-preset-end vercel"), - ); - expect(workspace.appendMissingEnvExampleKeys).toHaveBeenCalled(); - expect(logger.success).toHaveBeenCalledWith( - expect.stringContaining("Applied Vercel preset to env.ts"), - ); - }); - - it("uses package.json arkenv schema pointer when discovered", async () => { - vi.mocked(scanner.readArkenvConfig).mockResolvedValue({ - schema: "./src/config/env.ts", - }); - vi.mocked(workspace.exists).mockImplementation(async (p: string) => - p.includes("src/config/env.ts"), - ); - vi.mocked(workspace.readFile).mockResolvedValue(dedent` - import { type } from "@arkenv/core"; - - export const Env = type({ - DATABASE_URL: "string", - }); - `); - - const result = await useCase.execute({ - action: "apply", - provider: "vercel", - }); - - expect(result).toBe(true); - expect(workspace.writeFile).toHaveBeenCalledWith( - expect.stringContaining("src/config/env.ts"), - expect.stringContaining("// @arkenv-preset-start vercel"), - ); - }); - - it("honors --file override over package.json pointer", async () => { - vi.mocked(scanner.readArkenvConfig).mockResolvedValue({ - schema: "./src/config/env.ts", - }); - const customFile = path.resolve(process.cwd(), "custom/env.ts"); - vi.mocked(workspace.exists).mockImplementation( - async (p: string) => p === customFile, - ); - vi.mocked(workspace.readFile).mockResolvedValue(dedent` - import { type } from "@arkenv/core"; - - export const Env = type({ - DATABASE_URL: "string", - }); - `); - - const result = await useCase.execute({ - action: "apply", - provider: "vercel", - file: "custom/env.ts", - }); - - expect(result).toBe(true); - expect(workspace.writeFile).toHaveBeenCalledWith( - customFile, - expect.stringContaining("// @arkenv-preset-start vercel"), - ); - }); - - it("fails closed on unmarked key collision", async () => { - vi.mocked(workspace.exists).mockImplementation(async (p: string) => - p.endsWith("env.ts"), - ); - vi.mocked(workspace.readFile).mockResolvedValue(dedent` - import { type } from "@arkenv/core"; - - export const Env = type({ - DATABASE_URL: "string", - VERCEL: "string?", - }); - `); - - const result = await useCase.execute({ - action: "apply", - provider: "vercel", - }); - - expect(result).toBe(false); - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Collision"), - ); - expect(workspace.writeFile).not.toHaveBeenCalled(); - }); - - it("fails closed on malformed markers in schema", async () => { - vi.mocked(workspace.exists).mockImplementation(async (p: string) => - p.endsWith("env.ts"), - ); - vi.mocked(workspace.readFile).mockResolvedValue(dedent` - import { type } from "@arkenv/core"; - - export const Env = type({ - // @arkenv-preset-start vercel - VERCEL: "string?", - }); - `); - - const result = await useCase.execute({ - action: "apply", - provider: "vercel", - }); - - expect(result).toBe(false); - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Malformed preset markers"), - ); - }); - }); - - describe("preset remove", () => { - it("removes managed preset block from flat schema and updates .env.example", async () => { - vi.mocked(workspace.exists).mockImplementation(async (p: string) => - p.endsWith("env.ts"), - ); - vi.mocked(workspace.readFile).mockResolvedValue(dedent` - import { type } from "@arkenv/core"; - - export const Env = type({ - DATABASE_URL: "string", - // @arkenv-preset-start vercel - VERCEL: "string?", - VERCEL_ENV: "'production' | 'preview' | 'development'?", - // @arkenv-preset-end vercel - }); - `); - - const result = await useCase.execute({ - action: "remove", - provider: "vercel", - }); - - expect(result).toBe(true); - expect(workspace.writeFile).toHaveBeenCalledWith( - expect.stringContaining("env.ts"), - expect.not.stringContaining("VERCEL"), - ); - expect(workspace.removeEnvExampleKeys).toHaveBeenCalledWith( - expect.any(String), - expect.arrayContaining(["VERCEL", "VERCEL_ENV"]), - [], - ); - expect(logger.success).toHaveBeenCalledWith( - expect.stringContaining("Removed Vercel preset from env.ts"), - ); - }); - - it("fails closed on malformed markers during remove", async () => { - vi.mocked(workspace.exists).mockImplementation(async (p: string) => - p.endsWith("env.ts"), - ); - vi.mocked(workspace.readFile).mockResolvedValue(dedent` - import { type } from "@arkenv/core"; - - export const Env = type({ - // @arkenv-preset-start vercel - VERCEL: "string?", - }); - `); - - const result = await useCase.execute({ - action: "remove", - provider: "vercel", - }); - - expect(result).toBe(false); - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("Malformed preset markers"), - ); - - expect(workspace.writeFile).not.toHaveBeenCalled(); - }); - - it("does not touch .env.example when preset block was not present in schema", async () => { - vi.mocked(workspace.exists).mockImplementation(async (p: string) => - p.endsWith("env.ts"), - ); - vi.mocked(workspace.readFile).mockResolvedValue(dedent` - import { type } from "@arkenv/core"; - - export const Env = type({ - DATABASE_URL: "string", - }); - `); - - const result = await useCase.execute({ - action: "remove", - provider: "vercel", - }); - - expect(result).toBe(true); - expect(workspace.writeFile).not.toHaveBeenCalled(); - expect(workspace.removeEnvExampleKeys).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining("Vercel preset was not present"), - ); - }); - - it("only removes keys that were tracked by the removed managed block", async () => { - vi.mocked(workspace.exists).mockImplementation(async (p: string) => - p.endsWith("env.ts"), - ); - // Only VERCEL_URL is present in the managed block; other VERCEL_* keys are not in the block - vi.mocked(workspace.readFile).mockResolvedValue(dedent` - import { type } from "@arkenv/core"; - - export const Env = type({ - DATABASE_URL: "string", - // @arkenv-preset-start vercel - VERCEL_URL: "string?", - // @arkenv-preset-end vercel - }); - `); - - const result = await useCase.execute({ - action: "remove", - provider: "vercel", - }); - - expect(result).toBe(true); - expect(workspace.removeEnvExampleKeys).toHaveBeenCalledWith( - expect.any(String), - ["VERCEL_URL"], - [], - ); - }); - }); - - describe("detectValidator", () => { - it("detects zod from import statements", () => { - const code = 'import * as z from "zod";\nexport const env = arkenv({});'; - expect(detectValidator(code)).toBe("zod"); - }); - - it("detects valibot from import statements", () => { - const code = - 'import * as v from "valibot";\nexport const env = arkenv({});'; - expect(detectValidator(code)).toBe("valibot"); - }); - - it("defaults to arktype when no zod or valibot import is present", () => { - const code = - 'import arkenv from "./generated/env.gen";\nexport const env = arkenv({});'; - expect(detectValidator(code)).toBe("arktype"); - }); - - it("ignores commented-out zod imports", () => { - const code = - '// import { z } from "zod"\nimport arkenv from "./generated/env.gen";\nexport const env = arkenv({});'; - expect(detectValidator(code)).toBe("arktype"); - }); - - it("detects zod from multi-line import statements", () => { - const code = - 'import {\n z,\n} from "zod";\nexport const env = arkenv({});'; - expect(detectValidator(code)).toBe("zod"); - }); - - it("detects valibot from multi-line import statements", () => { - const code = - 'import {\n string,\n optional,\n} from "valibot";\nexport const env = arkenv({});'; - expect(detectValidator(code)).toBe("valibot"); - }); - - it("ignores multi-line commented-out valibot imports", () => { - const code = - '/*\n import * as v from "valibot"\n*/\nimport arkenv from "./generated/env.gen";\nexport const env = arkenv({});'; - expect(detectValidator(code)).toBe("arktype"); - }); - }); -}); diff --git a/packages/arkenv/src/cli/commands/preset.ts b/packages/arkenv/src/cli/commands/preset.ts deleted file mode 100644 index d46b528c1..000000000 --- a/packages/arkenv/src/cli/commands/preset.ts +++ /dev/null @@ -1,276 +0,0 @@ -import path from "node:path"; -import { - applyPresetToSchema, - removePresetFromSchema, - validateAndFindPresetBlocks, -} from "@/features/config-mutation"; -import { FRAMEWORK_CLIENT_PREFIXES } from "@/features/scaffold/frameworks"; -import { - getPresetKeys, - type HostPreset, - type HostProvider, - PRESETS, -} from "@/features/scaffold/presets"; -import { ERROR_CODES } from "@/shared/errors"; -import type { - LoggerPort, - ProjectScannerPort, - PromptPort, - WorkspacePort, -} from "@/shared/ports"; - -/** - * Detects the validator engine (Zod, Valibot, or ArkType) used in an env.ts schema file. - * Strips single-line and multi-line comments to avoid misclassifying commented-out code or string literals. - * - * @param code The source code of env.ts. - * @returns The detected validator engine. - */ -export function detectValidator(code: string): "zod" | "valibot" | "arktype" { - const cleanedCode = code - .replace(/\/\/.*/g, "") - .replace(/\/\*[\s\S]*?\*\//g, ""); - - if (/(?:^|\n)\s*import\s+[\s\S]*?from\s+['"]zod['"]/.test(cleanedCode)) { - return "zod"; - } - if (/(?:^|\n)\s*import\s+[\s\S]*?from\s+['"]valibot['"]/.test(cleanedCode)) { - return "valibot"; - } - return "arktype"; -} - -/** - * Input for the preset command. - */ -export type PresetInput = { - action: "apply" | "remove"; - provider?: HostProvider; - file?: string; - isForce?: boolean; - isYes?: boolean; -}; - -/** - * Use case for managing hosting presets (apply / remove / refresh). - */ -export class PresetUseCase { - constructor( - private readonly logger: LoggerPort, - private readonly workspace: WorkspacePort, - private readonly prompt: PromptPort, - private readonly scanner: ProjectScannerPort, - ) {} - - /** - * Executes the preset apply or remove command. - */ - async execute(input: PresetInput): Promise { - this.logger.interactiveStdout(true); - - try { - const cwd = process.cwd(); - - // 1. Git working tree check - const gitStatus = await this.scanner.checkGitStatus(cwd); - if (gitStatus.status === "dirty") { - if (input.isForce) { - this.logger.warn( - "Git working tree is not clean, but continuing due to --force flag.", - ); - } else { - this.logger.error( - "Git working tree is not clean. Commit or stash your changes before running arkenv preset.", - ); - this.logger.info("Use --force to bypass this check."); - this.logger.refuse( - { - code: ERROR_CODES.GIT_TREE_DIRTY, - message: "Git working tree is not clean.", - why: "Commit or stash your changes before running arkenv preset.", - retryWith: ["--force"], - nextActions: [ - { - kind: "run-command", - label: "Re-run with --force to bypass git working tree check", - command: `{bin} preset ${input.action} --force`, - }, - ], - }, - "preset", - ); - return false; - } - } else if (gitStatus.status === "unknown") { - this.logger.warn( - "Git working tree status could not be determined. Proceeding with caution.", - ); - } - - // 2. Resolve Provider - let provider = input.provider; - if (!provider) { - if (input.isYes) { - provider = "vercel"; - } else { - const actionLabel = input.action === "remove" ? "remove" : "apply"; - const selected = (await this.prompt.select( - `Select a hosting provider preset to ${actionLabel}:`, - Object.entries(PRESETS).map(([value, def]) => ({ - value, - label: def.label, - hint: def.hint, - })), - "vercel", - )) as HostPreset; - if (selected && selected !== "none") { - provider = selected; - } else { - return false; - } - } - } - - const providerName = PRESETS[provider]?.label || provider; - - // 3. Discover schema file - const discovery = await this.discoverSchema(cwd, input.file); - if (!discovery) { - return false; - } - - const tsConfigResult = await this.scanner.checkTsConfig(cwd); - const tsConfig = tsConfigResult.parsed || null; - const framework = await this.scanner.detectFramework(cwd, tsConfig); - const prefix = FRAMEWORK_CLIENT_PREFIXES[framework] || ""; - - const allPresetKeys = getPresetKeys(provider, prefix); - - if (input.action === "apply") { - const envPath = discovery.filePath; - const relEnvPath = path.relative(cwd, envPath); - - if (!(await this.workspace.exists(envPath))) { - this.logger.error(`Schema file not found at ${relEnvPath}.`); - return false; - } - - const code = await this.workspace.readFile(envPath); - const validator = detectValidator(code); - - const result = applyPresetToSchema(code, { - preset: provider, - framework, - validator, - }); - - if (!result.success || !result.code) { - this.logger.error( - result.error || `Failed to apply preset to ${relEnvPath}.`, - ); - return false; - } - - if (result.updated) { - await this.workspace.writeFile(envPath, result.code); - this.logger.success( - `Applied ${providerName} preset to ${relEnvPath}`, - ); - } else { - this.logger.info( - `${providerName} preset is already up-to-date in ${relEnvPath}`, - ); - } - - if (typeof this.workspace.appendMissingEnvExampleKeys === "function") { - await this.workspace.appendMissingEnvExampleKeys(cwd, allPresetKeys); - } - return true; - } - - // input.action === "remove" - const envPath = discovery.filePath; - const relEnvPath = path.relative(cwd, envPath); - - if (!(await this.workspace.exists(envPath))) { - this.logger.error(`Schema file not found at ${relEnvPath}.`); - return false; - } - - const code = await this.workspace.readFile(envPath); - const result = removePresetFromSchema(code, { - preset: provider, - }); - - if (!result.success || !result.code) { - this.logger.error( - result.error || `Failed to remove preset from ${relEnvPath}.`, - ); - return false; - } - - if (result.updated) { - await this.workspace.writeFile(envPath, result.code); - - const validation = validateAndFindPresetBlocks(result.code); - const remainingBlocks = validation.success ? validation.blocks : []; - const remainingKeys = remainingBlocks.flatMap((b) => b.keys); - - if (typeof this.workspace.removeEnvExampleKeys === "function") { - await this.workspace.removeEnvExampleKeys( - cwd, - result.removedKeys || [], - remainingKeys, - ); - } - - this.logger.success( - `Removed ${providerName} preset from ${relEnvPath}`, - ); - } else { - this.logger.info( - `${providerName} preset was not present in ${relEnvPath}`, - ); - } - - return true; - } finally { - this.logger.interactiveStdout(false); - } - } - - /** - * Discovers the flat schema file path. - */ - private async discoverSchema( - cwd: string, - fileOverride?: string, - ): Promise<{ filePath: string } | null> { - if (fileOverride) { - return { filePath: path.resolve(cwd, fileOverride) }; - } - - // Read pointer from nearest package.json - if (typeof this.scanner.readArkenvConfig === "function") { - const arkenvConfig = await this.scanner.readArkenvConfig(cwd); - if (arkenvConfig) { - return { filePath: path.resolve(cwd, arkenvConfig.schema) }; - } - } - - const flatCandidates = [ - path.resolve(cwd, "env.ts"), - path.resolve(cwd, "src/env.ts"), - ]; - for (const file of flatCandidates) { - if (await this.workspace.exists(file)) { - return { filePath: file }; - } - } - - this.logger.error( - "Could not locate your schema file. Add an 'arkenv' entry to package.json or specify --file .", - ); - return null; - } -} diff --git a/packages/arkenv/src/cli/composition.ts b/packages/arkenv/src/cli/composition.ts index aef44d514..7d3037cf3 100644 --- a/packages/arkenv/src/cli/composition.ts +++ b/packages/arkenv/src/cli/composition.ts @@ -5,12 +5,7 @@ import { NodeWorkspace, } from "@/adapters"; import { CLI } from "./cli"; -import { - CheckUseCase, - HelpUseCase, - InitUseCase, - PresetUseCase, -} from "./commands"; +import { CheckUseCase, HelpUseCase, InitUseCase } from "./commands"; /** * Bootstraps the application's dependency graph by composing @@ -35,7 +30,6 @@ export function compose( const schemaLoader = new JitiSchemaLoaderAdapter(jitiOptions); const initUseCase = new InitUseCase(logger, workspace, prompt, scanner); - const presetUseCase = new PresetUseCase(logger, workspace, prompt, scanner); const checkUseCase = new CheckUseCase( logger, workspace, @@ -50,7 +44,6 @@ export function compose( workspace, prompt, initUseCase, - presetUseCase, checkUseCase, helpUseCase, schemaLoader, diff --git a/packages/arkenv/src/features/config-mutation/config-mutation.test.ts b/packages/arkenv/src/features/config-mutation/config-mutation.test.ts deleted file mode 100644 index 948218be4..000000000 --- a/packages/arkenv/src/features/config-mutation/config-mutation.test.ts +++ /dev/null @@ -1,1071 +0,0 @@ -import dedent from "dedent"; -import { describe, expect, it } from "vitest"; -import { - applyPresetToSchema, - mutateEnvConfig, - removePresetFromSchema, - transformNextjsConfig, - transformViteConfig, - validateAndFindPresetBlocks, -} from "./config-mutation"; - -describe("config-mutation", () => { - describe("transformViteConfig", () => { - it("injects plugin into a standard vite.config.ts", async () => { - const initialContent = dedent` - import { defineConfig } from "vite" - export default defineConfig({ - plugins: [] - }) - `; - - const result = transformViteConfig({ code: initialContent }); - expect(result.success).toBe(true); - - expect(result.code).toContain( - 'import arkenvVitePlugin from "@arkenv/vite-plugin"', - ); - expect(result.code).toContain("arkenvVitePlugin()"); - }); - - it("injects zero-arg plugin even when envImportPath is provided", async () => { - const initialContent = dedent` - import { defineConfig } from "vite" - export default defineConfig({ - plugins: [] - }) - `; - - const result = transformViteConfig({ - code: initialContent, - envImportPath: "./env", - }); - expect(result.success).toBe(true); - - expect(result.code).toContain( - 'import arkenvVitePlugin from "@arkenv/vite-plugin"', - ); - expect(result.code).not.toContain("import { Env }"); - expect(result.code).toContain("arkenvVitePlugin()"); - }); - - it("injects plugin into a simple object export", async () => { - const initialContent = dedent` - export default { - plugins: [] - } - `; - - const result = transformViteConfig({ code: initialContent }); - expect(result.success).toBe(true); - expect(result.code).toContain("arkenvVitePlugin()"); - }); - - it("handles missing plugins array", async () => { - const initialContent = dedent` - export default { - build: {} - } - `; - - const result = transformViteConfig({ code: initialContent }); - expect(result.success).toBe(true); - expect(result.code).toContain("plugins: ["); - expect(result.code).toContain("arkenvVitePlugin()"); - }); - - it("does not duplicate plugin if already exists and returns updated: false", async () => { - const initialContent = dedent` - import arkenvVitePlugin from "@arkenv/vite-plugin" - export default { - plugins: [arkenvVitePlugin()] - } - `; - - const result = transformViteConfig({ code: initialContent }); - expect(result.success).toBe(true); - expect(result.updated).toBe(false); - }); - - it("returns updated: true when plugin is injected", async () => { - const initialContent = dedent` - export default { - plugins: [] - } - `; - - const result = transformViteConfig({ code: initialContent }); - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - }); - - it("preserves original indentation", async () => { - const initialContent = dedent` - export default { - plugins: [] - } - `; - - const result = transformViteConfig({ code: initialContent }); - expect(result.success).toBe(true); - expect(result.code).toContain(" plugins: ["); - }); - - it("preserves tab indentation", async () => { - const initialContent = "export default {\n\tplugins: []\n}"; - - const result = transformViteConfig({ code: initialContent }); - expect(result.success).toBe(true); - expect(result.code).toContain("\tplugins: ["); - }); - - it("returns failure for invalid/too complex config", async () => { - const initialContent = "export default someFunction()"; - - const result = transformViteConfig({ code: initialContent }); - expect(result).toMatchObject({ - success: false, - error: expect.stringContaining("Could not find default export object"), - }); - }); - }); - - describe("transformNextjsConfig", () => { - it("wraps a plain object export with withArkEnv", async () => { - const initialContent = dedent` - export default { - experimental: {} - } - `; - - const result = transformNextjsConfig({ code: initialContent }); - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain('from "@arkenv/nextjs/config"'); - expect(result.code).toContain("withArkEnv({"); - }); - - it("wraps a named variable export with withArkEnv", async () => { - const initialContent = dedent` - const nextConfig = { - experimental: {} - } - export default nextConfig - `; - - const result = transformNextjsConfig({ code: initialContent }); - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain('from "@arkenv/nextjs/config"'); - expect(result.code).toContain("withArkEnv(nextConfig)"); - }); - - it("returns updated: false if already wrapped with withArkEnv", async () => { - const initialContent = dedent` - import { withArkEnv } from "@arkenv/nextjs/config" - export default withArkEnv({ - experimental: {} - }) - `; - - const result = transformNextjsConfig({ code: initialContent }); - expect(result.success).toBe(true); - expect(result.updated).toBe(false); - }); - - it("returns updated: false if withArkEnv is referenced elsewhere", async () => { - const initialContent = dedent` - import { withArkEnv } from "@arkenv/nextjs/config" - const nextConfig = withArkEnv({ experimental: {} }); - export default nextConfig - `; - - const result = transformNextjsConfig({ code: initialContent }); - expect(result.success).toBe(true); - expect(result.updated).toBe(false); - }); - - it("returns failure for CommonJS module.exports", async () => { - const initialContent = dedent` - const nextConfig = { - experimental: {} - } - module.exports = nextConfig - `; - - const result = transformNextjsConfig({ code: initialContent }); - expect(result).toMatchObject({ - success: false, - error: expect.stringContaining("CommonJS"), - }); - }); - - it("returns failure when no default export exists", async () => { - const result = transformNextjsConfig({ code: "const x = 1;" }); - expect(result).toMatchObject({ - success: false, - error: expect.stringContaining("Could not find default export"), - }); - }); - - it("preserves import when wrapping", async () => { - const initialContent = dedent` - import type { NextConfig } from "next" - const nextConfig: NextConfig = { - experimental: {} - } - export default nextConfig - `; - - const result = transformNextjsConfig({ code: initialContent }); - expect(result.success).toBe(true); - expect(result.code).toContain('from "next"'); - expect(result.code).toContain("withArkEnv(nextConfig)"); - }); - - it("preserves trailing newline when present", async () => { - const initialContent = "export default { experimental: {} }\n"; - - const result = transformNextjsConfig({ code: initialContent }); - expect(result.success).toBe(true); - expect(result.code).toContain("withArkEnv({"); - expect(result.code).toMatch(/\n$/); - }); - - it("does not add trailing newline when absent", async () => { - const initialContent = "export default { experimental: {} }"; - - const result = transformNextjsConfig({ code: initialContent }); - expect(result.success).toBe(true); - expect(result.code).not.toMatch(/\n$/); - }); - - it("wraps a plain object export with codegen: false option when disableCodegen is true", async () => { - const initialContent = dedent` - export default { - experimental: {} - } - `; - - const result = transformNextjsConfig({ - code: initialContent, - disableCodegen: true, - }); - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain('from "@arkenv/nextjs/config"'); - expect(result.code).toContain("withArkEnv({"); - expect(result.code).toContain("codegen: false"); - }); - }); - - describe("validateAndFindPresetBlocks", () => { - it("parses single unsuffixed preset block correctly", () => { - const code = dedent` - export const env = arkenv({ - PORT: "number.port = 3000", - // @arkenv-preset-start vercel - VERCEL: "string?", - VERCEL_ENV: "'production' | 'preview' | 'development'?", - // @arkenv-preset-end vercel - }); - `; - const result = validateAndFindPresetBlocks(code); - expect(result.success).toBe(true); - if (result.success) { - expect(result.blocks).toHaveLength(1); - expect(result.blocks[0].role).toBeUndefined(); - expect(result.blocks[0]).toMatchObject({ - markerId: "vercel", - baseId: "vercel", - keys: ["VERCEL", "VERCEL_ENV"], - }); - } - }); - - it("parses role-suffixed preset blocks correctly", () => { - const code = dedent` - export const env = arkenv({ - // @arkenv-preset-start vercel:client - NEXT_PUBLIC_VERCEL_ENV: "string?", - // @arkenv-preset-end vercel:client - }); - `; - const result = validateAndFindPresetBlocks(code); - expect(result.success).toBe(true); - if (result.success) { - expect(result.blocks).toHaveLength(1); - expect(result.blocks[0]).toMatchObject({ - markerId: "vercel:client", - baseId: "vercel", - role: "client", - keys: ["NEXT_PUBLIC_VERCEL_ENV"], - }); - } - }); - - it("parses multiple stacked preset blocks", () => { - const code = dedent` - export const env = arkenv({ - // @arkenv-preset-start vercel - VERCEL: "string?", - // @arkenv-preset-end vercel - // @arkenv-preset-start netlify - NETLIFY: "string?", - // @arkenv-preset-end netlify - }); - `; - const result = validateAndFindPresetBlocks(code); - expect(result.success).toBe(true); - if (result.success) { - expect(result.blocks).toHaveLength(2); - expect(result.blocks[0].baseId).toBe("vercel"); - expect(result.blocks[1].baseId).toBe("netlify"); - } - }); - - it("fails closed on unclosed start marker", () => { - const code = dedent` - export const env = arkenv({ - // @arkenv-preset-start vercel - VERCEL: "string?", - }); - `; - const result = validateAndFindPresetBlocks(code); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toContain("unclosed"); - } - }); - - it("fails closed on unexpected end marker", () => { - const code = dedent` - export const env = arkenv({ - VERCEL: "string?", - // @arkenv-preset-end vercel - }); - `; - const result = validateAndFindPresetBlocks(code); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toContain("unexpected"); - } - }); - - it("fails closed on mismatched marker IDs", () => { - const code = dedent` - export const env = arkenv({ - // @arkenv-preset-start vercel - VERCEL: "string?", - // @arkenv-preset-end netlify - }); - `; - const result = validateAndFindPresetBlocks(code); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toContain("mismatched"); - } - }); - - it("fails closed on nested start markers", () => { - const code = dedent` - export const env = arkenv({ - // @arkenv-preset-start vercel - // @arkenv-preset-start netlify - VERCEL: "string?", - // @arkenv-preset-end netlify - // @arkenv-preset-end vercel - }); - `; - const result = validateAndFindPresetBlocks(code); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toContain("nested or unclosed"); - } - }); - }); - - describe("applyPresetToSchema / mutateEnvConfig", () => { - it("mutates flat env.ts with ArkType and wraps in markers", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - DATABASE_URL: "string", - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain("// @arkenv-preset-start vercel"); - expect(result.code).toContain('VERCEL: "string?"'); - expect(result.code).toContain( - "VERCEL_ENV: \"'production' | 'preview' | 'development'?\"", - ); - expect(result.code).toContain( - "NEXT_PUBLIC_VERCEL_ENV: \"'production' | 'preview' | 'development'?\"", - ); - expect(result.code).toContain("// @arkenv-preset-end vercel"); - }); - - it("mutates flat env.ts with Zod correctly", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - import * as z from "zod"; - - export const env = arkenv({ - DATABASE_URL: z.string(), - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "zod", - }); - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain("// @arkenv-preset-start vercel"); - expect(result.code).toContain("VERCEL: z.string().optional()"); - expect(result.code).toContain( - 'VERCEL_ENV: z.enum(["production", "preview", "development"]).optional()', - ); - expect(result.code).toContain("// @arkenv-preset-end vercel"); - }); - - it("mutates flat env.ts with Valibot correctly", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - import * as v from "valibot"; - - export const env = arkenv({ - DATABASE_URL: v.string(), - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "valibot", - }); - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain("// @arkenv-preset-start vercel"); - expect(result.code).toContain("VERCEL: v.optional(v.string())"); - expect(result.code).toContain( - 'VERCEL_ENV: v.optional(v.picklist(["production", "preview", "development"]))', - ); - expect(result.code).toContain("// @arkenv-preset-end vercel"); - }); - - it("refreshes (nuke-and-pave) existing preset block", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - DATABASE_URL: "string", - // @arkenv-preset-start vercel - VERCEL: "string?", - // @arkenv-preset-end vercel - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain('VERCEL: "string?"'); - expect(result.code).toContain( - "VERCEL_ENV: \"'production' | 'preview' | 'development'?\"", - ); - }); - - it("returns updated: false if managed block is already up-to-date", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - DATABASE_URL: "string", - // @arkenv-preset-start vercel - VERCEL: "string?", - VERCEL_ENV: "'production' | 'preview' | 'development'?", - VERCEL_URL: "string?", - NEXT_PUBLIC_VERCEL_ENV: "'production' | 'preview' | 'development'?", - NEXT_PUBLIC_VERCEL_URL: "string?", - // @arkenv-preset-end vercel - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - expect(result.success).toBe(true); - expect(result.updated).toBe(false); - }); - - it("fails closed when key collides with unmarked / user-owned key", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - DATABASE_URL: "string", - VERCEL: "string?", - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - expect(result.success).toBe(false); - expect(result.updated).toBe(false); - expect(result.error).toContain("Collision detected"); - expect(result.error).toContain("VERCEL"); - }); - - it("fails closed when key collides with another managed preset block", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - DATABASE_URL: "string", - // @arkenv-preset-start other - VERCEL: "string?", - // @arkenv-preset-end other - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - expect(result.success).toBe(false); - expect(result.updated).toBe(false); - expect(result.error).toContain("Collision detected"); - expect(result.error).toContain("other"); - }); - - it("fails closed when markers in file are malformed", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - // @arkenv-preset-start vercel - VERCEL: "string?", - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - expect(result.success).toBe(false); - expect(result.error).toContain("Malformed preset markers"); - }); - - it("supports custom markerId for preset blocks", () => { - const initialContent = dedent` - import arkenv from "@arkenv/nextjs"; - - export const env = arkenv({ - NEXT_PUBLIC_API_URL: "string", - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - markerId: "vercel:public", - targetKeys: ["NEXT_PUBLIC_VERCEL_ENV", "NEXT_PUBLIC_VERCEL_URL"], - }); - - expect(result.success).toBe(true); - expect(result.code).toContain("// @arkenv-preset-start vercel:public"); - expect(result.code).toContain( - "NEXT_PUBLIC_VERCEL_ENV: \"'production' | 'preview' | 'development'?\"", - ); - expect(result.code).toContain("// @arkenv-preset-end vercel:public"); - }); - }); - - describe("removePresetFromSchema", () => { - it("removes single unsuffixed preset block", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - DATABASE_URL: "string", - // @arkenv-preset-start vercel - VERCEL: "string?", - VERCEL_ENV: "'production' | 'preview' | 'development'?", - // @arkenv-preset-end vercel - }); - `; - - const result = removePresetFromSchema(initialContent, { - preset: "vercel", - }); - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).not.toContain("VERCEL"); - expect(result.code).not.toContain("@arkenv-preset-start"); - expect(result.code).toContain('DATABASE_URL: "string"'); - }); - - it("removes all role-suffixed blocks matching baseId", () => { - const initialContent = dedent` - export const env = arkenv({ - PORT: "number.port = 3000", - // @arkenv-preset-start vercel:client - NEXT_PUBLIC_VERCEL_ENV: "string?", - // @arkenv-preset-end vercel:client - // @arkenv-preset-start vercel:server - VERCEL: "string?", - // @arkenv-preset-end vercel:server - }); - `; - - const result = removePresetFromSchema(initialContent, { - preset: "vercel", - }); - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).not.toContain("vercel"); - expect(result.code).toContain('PORT: "number.port = 3000"'); - }); - - it("returns updated: false when preset is not in schema", () => { - const initialContent = dedent` - export const env = arkenv({ - PORT: "number.port = 3000", - }); - `; - - const result = removePresetFromSchema(initialContent, { - preset: "vercel", - }); - expect(result.success).toBe(true); - expect(result.updated).toBe(false); - }); - - it("fails closed on malformed markers during remove", () => { - const initialContent = dedent` - export const env = arkenv({ - // @arkenv-preset-start vercel - VERCEL: "string?", - }); - `; - - const result = removePresetFromSchema(initialContent, { - preset: "vercel", - }); - expect(result.success).toBe(false); - expect(result.error).toContain("Malformed preset markers"); - }); - - it("preserves other presets when removing one preset", () => { - const initialContent = dedent` - export const env = arkenv({ - DATABASE_URL: "string", - // @arkenv-preset-start vercel - VERCEL: "string?", - // @arkenv-preset-end vercel - // @arkenv-preset-start netlify - NETLIFY: "string?", - // @arkenv-preset-end netlify - }); - `; - - const result = removePresetFromSchema(initialContent, { - preset: "vercel", - }); - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).not.toContain("VERCEL"); - expect(result.code).toContain("NETLIFY"); - expect(result.code).toContain("// @arkenv-preset-start netlify"); - }); - - it("mutates SharedSchema exported as z.object", () => { - const initialContent = dedent` - import * as z from "zod"; - - export const SharedSchema = z.object({ - DATABASE_URL: z.string(), - }); - `; - - const result = mutateEnvConfig( - initialContent, - "vercel", - "nextjs", - "zod", - ["NEXT_PUBLIC_VERCEL_ENV"], - ); - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain( - 'NEXT_PUBLIC_VERCEL_ENV: z.enum(["production", "preview", "development"]).optional()', - ); - expect(result.code).not.toContain("VERCEL: z.string().optional()"); - }); - - it("mutates SharedSchema exported as v.object", () => { - const initialContent = dedent` - import * as v from "valibot"; - - export const SharedSchema = v.object({ - DATABASE_URL: v.string(), - }); - `; - - const result = mutateEnvConfig( - initialContent, - "vercel", - "nextjs", - "valibot", - ["NEXT_PUBLIC_VERCEL_ENV"], - ); - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain( - 'NEXT_PUBLIC_VERCEL_ENV: v.optional(v.picklist(["production", "preview", "development"]))', - ); - expect(result.code).not.toContain("VERCEL: v.optional(v.string())"); - }); - - it("mutates single-line arkenv({}) schema without corrupting output", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - export const env = arkenv({}); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - markerId: "vercel:client", - targetKeys: ["NEXT_PUBLIC_VERCEL_ENV", "NEXT_PUBLIC_VERCEL_URL"], - }); - - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toBe(dedent` - import arkenv from "./generated/env.gen"; - export const env = arkenv({ - // @arkenv-preset-start vercel:client - NEXT_PUBLIC_VERCEL_ENV: "'production' | 'preview' | 'development'?", - NEXT_PUBLIC_VERCEL_URL: "string?", - // @arkenv-preset-end vercel:client - }); - `); - }); - - it("mutates single-line schema with existing properties and detects collisions", () => { - const initialContent = - 'export const Env = type({ DATABASE_URL: "string" });'; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "vanilla", - validator: "arktype", - }); - - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain('DATABASE_URL: "string",'); - expect(result.code).toContain("// @arkenv-preset-start vercel"); - expect(result.code).toContain('VERCEL: "string?",'); - expect(result.code).toContain("// @arkenv-preset-end vercel"); - - // Collision on single-line schema - const collisionContent = 'export const Env = type({ VERCEL: "string" });'; - const collisionResult = applyPresetToSchema(collisionContent, { - preset: "vercel", - framework: "vanilla", - validator: "arktype", - }); - expect(collisionResult.success).toBe(false); - expect(collisionResult.error).toContain("Collision detected"); - }); - - it("handles inline comments containing braces correctly without truncating range", () => { - const initialContent = dedent` - import arkenv from "@arkenv/core"; - - export const env = arkenv({ - DATABASE_URL: "string", // comment with } brace - PORT: "number.port = 3000", - VERCEL: "string?", - }); - `; - - // Collision check should find VERCEL even though previous line comment had '}' - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "vanilla", - validator: "arktype", - }); - - expect(result.success).toBe(false); - expect(result.error).toContain("Collision detected"); - expect(result.error).toContain("VERCEL"); - }); - - it("handles string and template literals containing braces correctly", () => { - const initialContent = dedent` - import arkenv from "@arkenv/core"; - - export const env = arkenv({ - PATTERN: "'{id}'", - ANOTHER: '"{test}"', - PORT: "number.port = 3000", - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "vanilla", - validator: "arktype", - }); - - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain("// @arkenv-preset-start vercel"); - expect(result.code).toContain('PORT: "number.port = 3000",'); - expect(result.code).toContain("// @arkenv-preset-end vercel"); - }); - - it("mutates multiline arkenv(\\n { ... }\\n) schema call", () => { - const initialContent = `import arkenv from "@arkenv/nextjs"; - -export const env = arkenv( - { - NEXT_PUBLIC_URL: "string", - }, - { - emptyAsUndefined: true, - }, -);`; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - markerId: "vercel:public", - targetKeys: ["NEXT_PUBLIC_VERCEL_ENV", "NEXT_PUBLIC_VERCEL_URL"], - }); - - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toBe(`import arkenv from "@arkenv/nextjs"; - -export const env = arkenv( - { - NEXT_PUBLIC_URL: "string", - // @arkenv-preset-start vercel:public - NEXT_PUBLIC_VERCEL_ENV: "'production' | 'preview' | 'development'?", - NEXT_PUBLIC_VERCEL_URL: "string?", - // @arkenv-preset-end vercel:public - }, - { - emptyAsUndefined: true, - }, -);`); - }); - - it("fails closed on multi-line schema when colliding key is a second field on the same line", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - PORT: "number.port = 3000", - FOO: "string", VERCEL: "string?", - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - - expect(result.success).toBe(false); - expect(result.updated).toBe(false); - expect(result.error).toContain("Collision detected"); - expect(result.error).toContain("VERCEL"); - }); - - it("fails closed on multi-line schema when colliding key is on the same line inside another preset block", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - PORT: "number.port = 3000", - // @arkenv-preset-start other - OTHER_KEY: "string", VERCEL: "string?", - // @arkenv-preset-end other - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - - expect(result.success).toBe(false); - expect(result.updated).toBe(false); - expect(result.error).toContain("Collision detected"); - expect(result.error).toContain("other"); - }); - - it("fails closed when preset marker is missing an id", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - // @arkenv-preset-start - VERCEL: "string?", - // @arkenv-preset-end - }); - `; - - const result = validateAndFindPresetBlocks(initialContent); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toContain("Malformed preset markers"); - expect(result.error).toContain("missing or invalid preset id"); - } - - const applyResult = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - expect(applyResult.success).toBe(false); - expect(applyResult.error).toContain("Malformed preset markers"); - }); - - it("inserts trailing comma before line comment when last field has trailing comment without comma", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - PORT: "number.port = 3000" // tune - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain('PORT: "number.port = 3000", // tune'); - expect(result.code).not.toContain("// tune,"); - expect(result.code).toContain("// @arkenv-preset-start vercel"); - }); - - it("inserts trailing comma before block comment when last field has block comment without comma", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - PORT: "number.port = 3000" /* tune */ - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain('PORT: "number.port = 3000", /* tune */'); - expect(result.code).toContain("// @arkenv-preset-start vercel"); - }); - - it("does not duplicate trailing comma when last field already has comma before comment", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - PORT: "number.port = 3000", // tune - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain('PORT: "number.port = 3000", // tune'); - expect(result.code).not.toContain('PORT: "number.port = 3000",,'); - }); - - it("correctly ignores slashes in string literals when finding trailing comments", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - API_URL: "https://api.example.com" - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain('API_URL: "https://api.example.com",'); - expect(result.code).toContain("// @arkenv-preset-start vercel"); - }); - - it("adds trailing comma to last field when comment lines exist before closing brace", () => { - const initialContent = dedent` - import arkenv from "./generated/env.gen"; - - export const env = arkenv({ - PORT: "number.port = 3000" - // comment before closing brace - }); - `; - - const result = applyPresetToSchema(initialContent, { - preset: "vercel", - framework: "nextjs", - validator: "arktype", - }); - - expect(result.success).toBe(true); - expect(result.updated).toBe(true); - expect(result.code).toContain('PORT: "number.port = 3000",'); - expect(result.code).toContain("// @arkenv-preset-start vercel"); - }); - }); -}); diff --git a/packages/arkenv/src/features/config-mutation/config-mutation.ts b/packages/arkenv/src/features/config-mutation/config-mutation.ts deleted file mode 100644 index e7fcc49d8..000000000 --- a/packages/arkenv/src/features/config-mutation/config-mutation.ts +++ /dev/null @@ -1,1276 +0,0 @@ -import { - builders, - detectCodeFormat, - generateCode, - parseModule, -} from "magicast"; -import { FRAMEWORK_CLIENT_PREFIXES } from "@/features/scaffold/frameworks"; -import type { Framework, Validator } from "@/features/scaffold/plan"; -import { getPresetKeys, type HostPreset } from "@/features/scaffold/presets"; -import { - DIALECTS, - tryFormatPresetFieldValue, -} from "@/features/scaffold/validators/dialects"; -import type { BootstrapResult } from "@/shared/ports"; - -/** - * Input for transforming a configuration file. - */ -export type MutationInput = { - code: string; - envImportPath?: string; - disableCodegen?: boolean | undefined; -}; - -/** - * Normalizes named import spacing in generated code. - * magicast produces `import {Foo}`; this ensures `import { Foo }`. - */ -function normalizeImportSpacing(code: string): string { - return code.replace( - /import\s*\{([^\n}]*)\}\s*from/g, - (_match, p1) => `import { ${p1.trim()} } from`, - ); -} - -/** - * Preserves the trailing newline of the original file if present. - * magicast strips trailing newlines; this restores them. - */ -function preserveTrailingNewline(code: string, originalCode: string): string { - return originalCode.endsWith("\n") && !code.endsWith("\n") - ? `${code}\n` - : code; -} - -/** - * Transforms a Vite configuration file by injecting the ArkEnv Vite plugin. - * - * @param input The configuration code and optional import path. - * @returns The result of the bootstrap operation, potentially including the updated code. - */ -export function transformViteConfig( - input: MutationInput, -): BootstrapResult & { code?: string } { - try { - const mod = parseModule(input.code); - const initialCode = input.code; - - // 1. Find the plugins array - let config = mod.exports.default; - - // Handle defineConfig({...}) wrapper - if ( - config && - typeof config === "object" && - "$type" in config && - config.$type === "function-call" - ) { - const call = config as { $callee?: string; $args?: any[] }; - const callee = call.$callee || JSON.stringify(config); - if (callee === "defineConfig" && call.$args) { - const arg = call.$args[0]; - // Guard against defineConfig((env) => ({...})) callback form - if ( - arg && - typeof arg === "object" && - "$type" in arg && - (arg.$type === "arrow-function-expression" || - arg.$type === "function-expression") - ) { - return { - success: false, - updated: false, - error: - "The 'defineConfig' callback form is currently not supported for automatic mutation. Please add the plugin manually.", - }; - } - config = arg; - } - } - - if ( - !config || - typeof config !== "object" || - (typeof config === "object" && "$type" in config) - ) { - return { - success: false, - updated: false, - error: "Could not find default export object in Vite config", - }; - } - - if (!config.plugins) { - config.plugins = []; - } - - if (Array.isArray(config.plugins)) { - // Check if already exists using a word-boundary regex to avoid false positives - const hasPlugin = /\barkenv(?:Vite)?Plugin\b/.test(initialCode); - - if (!hasPlugin) { - // Add imports - mod.imports.$add({ - from: "@arkenv/vite-plugin", - local: "arkenvVitePlugin", - imported: "default", - }); - - config.plugins.push("__ARK_PLUGIN_PLACEHOLDER__"); - } else { - // Already has plugin, nothing to do - return { success: true, updated: false }; - } - } else { - return { - success: false, - updated: false, - error: "The 'plugins' property in your Vite config is not an array.", - }; - } - - let code = generateCode(mod, { - format: detectCodeFormat(initialCode), - }).code; - const pluginCall = "arkenvVitePlugin()"; - code = code.replace(/['"]__ARK_PLUGIN_PLACEHOLDER__['"]/g, pluginCall); - code = normalizeImportSpacing(code); - code = preserveTrailingNewline(code, initialCode); - - return { success: true, updated: true, code }; - } catch (e: unknown) { - const error = e instanceof Error ? e.message : String(e); - return { - success: false, - updated: false, - error: `Failed to parse Vite config: ${error}`, - }; - } -} - -/** - * Transform a Next.js configuration file by wrapping the default export with `withArkEnv`. - * - * @param input The configuration code and optional import path - * @returns The result of the bootstrap operation, potentially including the updated code - */ -export function transformNextjsConfig( - input: MutationInput, -): BootstrapResult & { code?: string } { - try { - const initialCode = input.code; - - // Check for CommonJS - can't auto-mutate - if (/module\.exports\b/.test(initialCode)) { - return { - success: false, - updated: false, - error: - "CommonJS is not supported for automatic mutation. Please wrap your config with `withArkEnv` manually.", - }; - } - - const mod = parseModule(initialCode); - - // Verify there's a default export - if (!mod.exports.default) { - return { - success: false, - updated: false, - error: "Could not find default export in Next.js config", - }; - } - - // Check if already wrapped with withArkEnv using the AST - if ( - typeof mod.exports.default === "object" && - "$type" in (mod.exports.default as object) && - (mod.exports.default as { $type?: string }).$type === "function-call" && - (mod.exports.default as { $callee?: string }).$callee === "withArkEnv" - ) { - return { success: true, updated: false }; - } - - // Also check via regex for cases where withArkEnv is used inline - if (/\bwithArkEnv\b/.test(initialCode)) { - return { success: true, updated: false }; - } - - // Add import - mod.imports.$add({ - from: "@arkenv/nextjs/config", - imported: "withArkEnv", - }); - - // Wrap the default export with withArkEnv(...) using the AST - if (input.disableCodegen) { - mod.exports.default = builders.functionCall( - "withArkEnv", - mod.exports.default, - { codegen: false }, - ); - } else { - mod.exports.default = builders.functionCall( - "withArkEnv", - mod.exports.default, - ); - } - - let code = generateCode(mod, { - format: detectCodeFormat(initialCode), - }).code; - code = normalizeImportSpacing(code); - code = preserveTrailingNewline(code, initialCode); - - return { success: true, updated: true, code }; - } catch (e: unknown) { - const error = e instanceof Error ? e.message : String(e); - return { - success: false, - updated: false, - error: `Failed to parse Next.js config: ${error}`, - }; - } -} - -/** - * Transform a Nuxt configuration file by adding `@arkenv/nuxt/module` to its modules. - * - * @param input The configuration code and optional import path - * @returns The result of the bootstrap operation, potentially including the updated code - */ -export function transformNuxtConfig( - input: MutationInput, -): BootstrapResult & { code?: string } { - try { - const initialCode = input.code; - const mod = parseModule(initialCode); - - let config = mod.exports.default; - - // Handle defineNuxtConfig({...}) wrapper - if ( - config && - typeof config === "object" && - "$type" in config && - config.$type === "function-call" - ) { - const call = config as { $callee?: string; $args?: any[] }; - const callee = call.$callee || JSON.stringify(config); - if (callee === "defineNuxtConfig" && call.$args) { - config = call.$args[0]; - } - } - - if ( - !config || - typeof config !== "object" || - (typeof config === "object" && "$type" in config) - ) { - return { - success: false, - updated: false, - error: "Could not find default export object in Nuxt config", - }; - } - - if (!config.modules) { - config.modules = []; - } - - if (Array.isArray(config.modules)) { - const hasModule = config.modules.includes("@arkenv/nuxt/module"); - - if (!hasModule) { - config.modules.push("@arkenv/nuxt/module"); - } else { - return { success: true, updated: false }; - } - } else { - return { - success: false, - updated: false, - error: "The 'modules' property in your Nuxt config is not an array.", - }; - } - - let code = generateCode(mod, { - format: detectCodeFormat(initialCode), - }).code; - code = normalizeImportSpacing(code); - code = preserveTrailingNewline(code, initialCode); - - return { success: true, updated: true, code }; - } catch (e: unknown) { - const error = e instanceof Error ? e.message : String(e); - return { - success: false, - updated: false, - error: `Failed to parse Nuxt config: ${error}`, - }; - } -} - -/** - * Resolve a hosting-preset key to a validator-specific schema fragment. - * - * Uses v1 dialect renderers (same as scaffold codegen) so add-host and - * mutateEnvConfig output stay aligned with `arkenv init` field syntax. - */ -export function getFieldDefinition( - key: string, - validator: Validator, - prefix: string, - preset: HostPreset, -): string { - const dialect = DIALECTS[validator]; - return ( - tryFormatPresetFieldValue(dialect, key, prefix, preset) ?? - dialect.formatOptionalString() - ); -} - -/** - * Represents a parsed managed preset comment block. - */ -export type PresetBlock = { - markerId: string; - baseId: string; - role?: string; - startLineIndex: number; - endLineIndex: number; - rawContent: string; - innerContent: string; - keys: string[]; -}; - -/** - * Validates and finds all managed preset comment blocks in the given source code. - * Fails closed on any malformed or unbalanced start/end markers. - * - * @param code The source code to inspect. - * @returns Result object with blocks on success or error message on failure. - */ -export function validateAndFindPresetBlocks( - code: string, -): - | { success: true; blocks: PresetBlock[] } - | { success: false; error: string } { - const lines = code.split(/\r?\n/); - const blocks: PresetBlock[] = []; - let activeStart: { - markerId: string; - startLineIndex: number; - } | null = null; - - const anyStartRegex = /^\s*\/\/\s*@arkenv-preset-start(?:\s+(.*))?$/; - const anyEndRegex = /^\s*\/\/\s*@arkenv-preset-end(?:\s+(.*))?$/; - const validIdRegex = /^[a-zA-Z0-9_:-]+$/; - const inlineKeyRegex = - /(?:^|[,{\s])([A-Za-z_][A-Za-z0-9_]*|'[^']+'|"[^"]+")\s*:/g; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const startMatch = line.match(anyStartRegex); - if (startMatch) { - const rawMarkerId = startMatch[1]?.trim(); - if (!rawMarkerId || !validIdRegex.test(rawMarkerId)) { - return { - success: false, - error: `Malformed preset markers: missing or invalid preset id in "@arkenv-preset-start" at line ${i + 1}.`, - }; - } - if (activeStart) { - return { - success: false, - error: `Malformed preset markers: nested or unclosed "@arkenv-preset-start ${activeStart.markerId}" before line ${i + 1}.`, - }; - } - activeStart = { - markerId: rawMarkerId, - startLineIndex: i, - }; - continue; - } - - const endMatch = line.match(anyEndRegex); - if (endMatch) { - const endMarkerId = endMatch[1]?.trim(); - if (!endMarkerId || !validIdRegex.test(endMarkerId)) { - return { - success: false, - error: `Malformed preset markers: missing or invalid preset id in "@arkenv-preset-end" at line ${i + 1}.`, - }; - } - if (!activeStart) { - return { - success: false, - error: `Malformed preset markers: unexpected "@arkenv-preset-end ${endMarkerId}" without matching start marker at line ${i + 1}.`, - }; - } - if (activeStart.markerId !== endMarkerId) { - return { - success: false, - error: `Malformed preset markers: mismatched start "${activeStart.markerId}" (line ${activeStart.startLineIndex + 1}) and end "${endMarkerId}" (line ${i + 1}).`, - }; - } - - const blockLines = lines.slice(activeStart.startLineIndex, i + 1); - const innerLines = lines.slice(activeStart.startLineIndex + 1, i); - const keys: string[] = []; - - for (const innerLine of innerLines) { - for (const match of innerLine.matchAll(inlineKeyRegex)) { - const rawKey = match[1]; - const cleanKey = - rawKey.startsWith("'") || rawKey.startsWith('"') - ? rawKey.slice(1, -1) - : rawKey; - keys.push(cleanKey); - } - } - - const parsedId = parseMarkerId(activeStart.markerId); - blocks.push({ - markerId: activeStart.markerId, - baseId: parsedId.baseId, - ...(parsedId.role !== undefined ? { role: parsedId.role } : {}), - startLineIndex: activeStart.startLineIndex, - endLineIndex: i, - rawContent: blockLines.join("\n"), - innerContent: innerLines.join("\n"), - keys, - }); - - activeStart = null; - } - } - - if (activeStart) { - return { - success: false, - error: `Malformed preset markers: unclosed "@arkenv-preset-start ${activeStart.markerId}" starting at line ${activeStart.startLineIndex + 1}.`, - }; - } - - return { success: true, blocks }; -} - -/** - * Parses the marker ID string into base ID and optional role. - */ -function parseMarkerId(markerId: string): { baseId: string; role?: string } { - const colonIndex = markerId.indexOf(":"); - if (colonIndex === -1) { - return { baseId: markerId }; - } - return { - baseId: markerId.slice(0, colonIndex), - role: markerId.slice(colonIndex + 1), - }; -} - -/** - * Options for applying a preset to a schema source string. - */ -export type ApplyPresetOptions = { - preset: HostPreset; - framework: Framework; - validator: Validator; - targetKeys?: string[]; - markerId?: string; -}; - -/** - * Scans for the matching closing brace '}' starting from a given '{' position, - * correctly ignoring braces inside single-quoted strings, double-quoted strings, - * template literals, single-line comments, and multi-line comments. - */ -function scanMatchingBrace( - lines: string[], - startLine: number, - startChar: number, -): { endLine: number; endChar: number } | null { - let depth = 0; - let inSingleQuote = false; - let inDoubleQuote = false; - let inTemplate = false; - const templateStack: number[] = []; - let inBlockComment = false; - - for (let i = startLine; i < lines.length; i++) { - const line = lines[i]; - const startCol = i === startLine ? startChar : 0; - let inLineComment = false; - - for (let c = startCol; c < line.length; c++) { - const ch = line[c]; - const nextCh = c + 1 < line.length ? line[c + 1] : ""; - const prevCh = c > 0 ? line[c - 1] : ""; - - if (inLineComment) { - break; - } - - if (inBlockComment) { - if (ch === "*" && nextCh === "/") { - inBlockComment = false; - c++; - } - continue; - } - - if (inSingleQuote) { - if (ch === "'" && prevCh !== "\\") { - inSingleQuote = false; - } - continue; - } - - if (inDoubleQuote) { - if (ch === '"' && prevCh !== "\\") { - inDoubleQuote = false; - } - continue; - } - - if (inTemplate) { - if (ch === "`" && prevCh !== "\\") { - inTemplate = false; - } else if (ch === "$" && nextCh === "{" && prevCh !== "\\") { - templateStack.push(depth); - inTemplate = false; - depth++; - c++; - } - continue; - } - - // Not inside any string or comment: - if (ch === "/" && nextCh === "/") { - inLineComment = true; - c++; - continue; - } - - if (ch === "/" && nextCh === "*") { - inBlockComment = true; - c++; - continue; - } - - if (ch === "'") { - inSingleQuote = true; - continue; - } - - if (ch === '"') { - inDoubleQuote = true; - continue; - } - - if (ch === "`") { - inTemplate = true; - continue; - } - - if (ch === "{") { - depth++; - } else if (ch === "}") { - depth--; - if (depth === 0) { - return { endLine: i, endChar: c }; - } - if ( - templateStack.length > 0 && - depth === templateStack[templateStack.length - 1] - ) { - templateStack.pop(); - inTemplate = true; - } - } - } - } - - return null; -} - -/** - * Locates the opening brace '{' of the schema object literal, correctly handling - * multiline calls where '{' is placed on subsequent lines (e.g. arkenv(\n { ... })). - */ -function findSchemaOpeningBrace( - lines: string[], -): { line: number; char: number } | null { - const schemaCallRegex = /\b(?:arkenv|type|z\.object|v\.object)\s*\(/; - - for (let i = 0; i < lines.length; i++) { - const match = lines[i].match(schemaCallRegex); - if (match && match.index !== undefined) { - let inSingleQuote = false; - let inDoubleQuote = false; - let inTemplate = false; - let inBlockComment = false; - - for (let lineIdx = i; lineIdx < lines.length; lineIdx++) { - const line = lines[lineIdx]; - const colStart = lineIdx === i ? match.index + match[0].length : 0; - let inLineComment = false; - - for (let c = colStart; c < line.length; c++) { - const ch = line[c]; - const nextCh = c + 1 < line.length ? line[c + 1] : ""; - const prevCh = c > 0 ? line[c - 1] : ""; - - if (inLineComment) break; - - if (inBlockComment) { - if (ch === "*" && nextCh === "/") { - inBlockComment = false; - c++; - } - continue; - } - - if (inSingleQuote) { - if (ch === "'" && prevCh !== "\\") inSingleQuote = false; - continue; - } - - if (inDoubleQuote) { - if (ch === '"' && prevCh !== "\\") inDoubleQuote = false; - continue; - } - - if (inTemplate) { - if (ch === "`" && prevCh !== "\\") inTemplate = false; - continue; - } - - if (ch === "/" && nextCh === "/") { - inLineComment = true; - c++; - continue; - } - - if (ch === "/" && nextCh === "*") { - inBlockComment = true; - c++; - continue; - } - - if (ch === "'") { - inSingleQuote = true; - continue; - } - - if (ch === '"') { - inDoubleQuote = true; - continue; - } - - if (ch === "`") { - inTemplate = true; - continue; - } - - if (ch === "{") { - return { line: lineIdx, char: c }; - } - - if (ch === ";" && lineIdx > i) { - break; - } - } - } - } - } - - // Fallback: search after `export const ... =` - const exportConstRegex = /\bexport\s+const\s+[\w$]+\s*=\s*/; - for (let i = 0; i < lines.length; i++) { - const match = lines[i].match(exportConstRegex); - if (match && match.index !== undefined) { - let inSingleQuote = false; - let inDoubleQuote = false; - let inTemplate = false; - let inBlockComment = false; - - for (let lineIdx = i; lineIdx < lines.length; lineIdx++) { - const line = lines[lineIdx]; - const colStart = lineIdx === i ? match.index + match[0].length : 0; - let inLineComment = false; - - for (let c = colStart; c < line.length; c++) { - const ch = line[c]; - const nextCh = c + 1 < line.length ? line[c + 1] : ""; - const prevCh = c > 0 ? line[c - 1] : ""; - - if (inLineComment) break; - - if (inBlockComment) { - if (ch === "*" && nextCh === "/") { - inBlockComment = false; - c++; - } - continue; - } - - if (inSingleQuote) { - if (ch === "'" && prevCh !== "\\") inSingleQuote = false; - continue; - } - - if (inDoubleQuote) { - if (ch === '"' && prevCh !== "\\") inDoubleQuote = false; - continue; - } - - if (inTemplate) { - if (ch === "`" && prevCh !== "\\") inTemplate = false; - continue; - } - - if (ch === "/" && nextCh === "/") { - inLineComment = true; - c++; - continue; - } - - if (ch === "/" && nextCh === "*") { - inBlockComment = true; - c++; - continue; - } - - if (ch === "'") { - inSingleQuote = true; - continue; - } - - if (ch === '"') { - inDoubleQuote = true; - continue; - } - - if (ch === "`") { - inTemplate = true; - continue; - } - - if (ch === "{") { - return { line: lineIdx, char: c }; - } - - if (ch === ";" && lineIdx > i) { - break; - } - } - } - } - } - - return null; -} - -/** - * Finds the schema object literal boundaries in source code. - */ -function findSchemaObjectRange(lines: string[]): { - startLineIndex: number; - endLineIndex: number; - startCharIndex: number; - endCharIndex: number; - isSingleLine: boolean; - indent: string; -} | null { - const opening = findSchemaOpeningBrace(lines); - if (!opening) return null; - - const match = scanMatchingBrace(lines, opening.line, opening.char); - if (!match) return null; - - const startLineIndex = opening.line; - const endLineIndex = match.endLine; - const startCharIndex = opening.char; - const endCharIndex = match.endChar; - const isSingleLine = startLineIndex === endLineIndex; - - // Determine indent from lines inside the object, or default to standard indent - let indent = "\t"; - if (!isSingleLine) { - for (let i = startLineIndex + 1; i < endLineIndex; i++) { - const m = lines[i].match(/^(\s+)\S/); - if (m) { - indent = m[1]; - break; - } - } - if (indent === "\t" && lines[startLineIndex].match(/^\s+/)) { - const baseIndent = lines[startLineIndex].match(/^(\s+)/)?.[1] || ""; - indent = baseIndent.includes(" ") - ? `${baseIndent} ` - : `${baseIndent}\t`; - } - } else { - const baseIndent = lines[startLineIndex].match(/^(\s*)/)?.[1] || ""; - indent = baseIndent.includes(" ") ? `${baseIndent} ` : `${baseIndent}\t`; - if (!indent.trim().length && !baseIndent) { - indent = "\t"; - } - } - - return { - startLineIndex, - endLineIndex, - startCharIndex, - endCharIndex, - isSingleLine, - indent, - }; -} - -/** - * Applies a preset to an env schema file by inserting or refreshing managed comment blocks. - * Fails closed on any key collisions with user-owned/unmarked keys or other presets, - * or on malformed markers. - * - * @param code The environment configuration code. - * @param options Preset options. - * @returns The mutation result. - */ -export function applyPresetToSchema( - code: string, - options: ApplyPresetOptions, -): { - success: boolean; - updated: boolean; - code?: string; - error?: string; - proposedFields: Record; -} { - const { preset, framework, validator, targetKeys } = options; - const prefix = FRAMEWORK_CLIENT_PREFIXES[framework] || ""; - const keysToMutate = targetKeys ?? getPresetKeys(preset, prefix); - const proposedFields: Record = {}; - - for (const key of keysToMutate) { - proposedFields[key] = getFieldDefinition(key, validator, prefix, preset); - } - - // 1. Validate markers in the file - const validation = validateAndFindPresetBlocks(code); - if (!validation.success) { - return { - success: false, - updated: false, - error: validation.error, - proposedFields, - }; - } - - const blocks = validation.blocks; - const lines = code.split(/\r?\n/); - const schemaRange = findSchemaObjectRange(lines); - - if (!schemaRange) { - return { - success: false, - updated: false, - error: "Could not find schema object literal in schema file.", - proposedFields, - }; - } - - const markerId = options.markerId || preset; - - // 2. Extract keys outside managed blocks and in other presets - const unmarkedKeys = new Set(); - const otherPresetKeys = new Map(); // key -> otherPresetId - const inlineKeyRegex = - /(?:^|[,{\s])([A-Za-z_][A-Za-z0-9_]*|'[^']+'|"[^"]+")\s*:/g; - - if (schemaRange.isSingleLine) { - const line = lines[schemaRange.startLineIndex]; - const inside = line.slice( - schemaRange.startCharIndex + 1, - schemaRange.endCharIndex, - ); - // Parse any inline keys inside { ... } - for (const match of inside.matchAll(inlineKeyRegex)) { - const rawKey = match[1]; - const cleanKey = - rawKey.startsWith("'") || rawKey.startsWith('"') - ? rawKey.slice(1, -1) - : rawKey; - unmarkedKeys.add(cleanKey); - } - } else { - for ( - let i = schemaRange.startLineIndex; - i <= schemaRange.endLineIndex; - i++ - ) { - const containingBlock = blocks.find( - (b) => i >= b.startLineIndex && i <= b.endLineIndex, - ); - - let textToScan = lines[i]; - if (i === schemaRange.startLineIndex) { - textToScan = textToScan.slice(schemaRange.startCharIndex + 1); - } else if (i === schemaRange.endLineIndex) { - textToScan = textToScan.slice(0, schemaRange.endCharIndex); - } - - if (!textToScan.trim()) continue; - - if (!containingBlock) { - for (const match of textToScan.matchAll(inlineKeyRegex)) { - const rawKey = match[1]; - const cleanKey = - rawKey.startsWith("'") || rawKey.startsWith('"') - ? rawKey.slice(1, -1) - : rawKey; - unmarkedKeys.add(cleanKey); - } - } else if (containingBlock.baseId !== preset) { - for (const match of textToScan.matchAll(inlineKeyRegex)) { - const rawKey = match[1]; - const cleanKey = - rawKey.startsWith("'") || rawKey.startsWith('"') - ? rawKey.slice(1, -1) - : rawKey; - otherPresetKeys.set(cleanKey, containingBlock.baseId); - } - } - } - } - - // 3. Collision Checks (Fail Closed) - for (const key of keysToMutate) { - if (unmarkedKeys.has(key)) { - return { - success: false, - updated: false, - error: `Collision detected: Key "${key}" already exists outside managed preset blocks (user-owned or legacy unmarked). Remove or migrate it before applying preset "${preset}".`, - proposedFields, - }; - } - if (otherPresetKeys.has(key)) { - const conflictingPreset = otherPresetKeys.get(key); - return { - success: false, - updated: false, - error: `Collision detected: Key "${key}" conflicts with existing managed preset "${conflictingPreset}".`, - proposedFields, - }; - } - } - - const indent = schemaRange.indent; - const blockLines: string[] = [ - `${indent}// @arkenv-preset-start ${markerId}`, - ...keysToMutate.map((key) => `${indent}${key}: ${proposedFields[key]},`), - `${indent}// @arkenv-preset-end ${markerId}`, - ]; - - // 4. Refresh existing block or Insert new block - const existingBlock = blocks.find((b) => b.markerId === markerId); - - if (existingBlock) { - // Check if identical - const currentRaw = lines - .slice(existingBlock.startLineIndex, existingBlock.endLineIndex + 1) - .join("\n"); - const newRaw = blockLines.join("\n"); - - if (currentRaw === newRaw) { - return { - success: true, - updated: false, - code, - proposedFields, - }; - } - - // Nuke-and-pave - lines.splice( - existingBlock.startLineIndex, - existingBlock.endLineIndex - existingBlock.startLineIndex + 1, - ...blockLines, - ); - - return { - success: true, - updated: true, - code: lines.join("\n"), - proposedFields, - }; - } - - // Insert into schema object - if (schemaRange.isSingleLine) { - const line = lines[schemaRange.startLineIndex]; - const beforeBrace = line.slice(0, schemaRange.startCharIndex + 1); - const insideContent = line - .slice(schemaRange.startCharIndex + 1, schemaRange.endCharIndex) - .trim(); - const afterBrace = line.slice(schemaRange.endCharIndex); - const baseIndent = line.match(/^(\s*)/)?.[1] || ""; - - if (insideContent) { - const commentIdx = findTrailingCommentIndex(insideContent); - let formattedInside = insideContent; - if (commentIdx !== -1) { - const codePart = insideContent.slice(0, commentIdx); - const commentPart = insideContent.slice(commentIdx); - if (codePart.trim() && !codePart.trimEnd().endsWith(",")) { - const trimmedCode = codePart.trimEnd(); - const trailingWs = codePart.slice(trimmedCode.length); - formattedInside = `${trimmedCode},${trailingWs}${commentPart}`; - } - } else if (!insideContent.endsWith(",")) { - formattedInside = `${insideContent},`; - } - lines.splice( - schemaRange.startLineIndex, - 1, - beforeBrace, - `${indent}${formattedInside}`, - ...blockLines, - `${baseIndent}${afterBrace.trimStart()}`, - ); - } else { - lines.splice( - schemaRange.startLineIndex, - 1, - beforeBrace, - ...blockLines, - `${baseIndent}${afterBrace.trimStart()}`, - ); - } - - return { - success: true, - updated: true, - code: lines.join("\n"), - proposedFields, - }; - } - - // Multi-line: Insert before the closing brace - // Ensure the line before has a trailing comma if it's a field - addTrailingCommaToLastField( - lines, - schemaRange.startLineIndex, - schemaRange.endLineIndex, - schemaRange.startCharIndex, - ); - - lines.splice(schemaRange.endLineIndex, 0, ...blockLines); - - return { - success: true, - updated: true, - code: lines.join("\n"), - proposedFields, - }; -} - -/** - * Scans a line and returns the starting index of a trailing `//` or `/*` comment, - * correctly ignoring slashes inside string literals or template literals. - * Returns -1 if no trailing comment is present. - */ -function findTrailingCommentIndex(line: string): number { - let inSingle = false; - let inDouble = false; - let inBacktick = false; - let isEscaped = false; - - for (let i = 0; i < line.length; i++) { - const char = line[i]; - - if (isEscaped) { - isEscaped = false; - continue; - } - - if (char === "\\") { - isEscaped = true; - continue; - } - - if (char === "'" && !inDouble && !inBacktick) { - inSingle = !inSingle; - continue; - } - if (char === '"' && !inSingle && !inBacktick) { - inDouble = !inDouble; - continue; - } - if (char === "`" && !inSingle && !inDouble) { - inBacktick = !inBacktick; - continue; - } - - if (!inSingle && !inDouble && !inBacktick) { - if (char === "/" && i + 1 < line.length) { - if (line[i + 1] === "/" || line[i + 1] === "*") { - return i; - } - } - } - } - - return -1; -} - -/** - * Ensures the last field preceding the closing brace has a trailing comma, - * inserting the comma before any trailing comment rather than inside it. - */ -function addTrailingCommaToLastField( - lines: string[], - startLineIndex: number, - endLineIndex: number, - startCharIndex: number, -): void { - for (let i = endLineIndex - 1; i >= startLineIndex; i--) { - const rawLine = lines[i]; - let lineText = rawLine; - if (i === startLineIndex) { - lineText = lineText.slice(startCharIndex + 1); - } - - const trimmed = lineText.trim(); - if (!trimmed) { - continue; - } - - // Skip pure comment lines - if ( - trimmed.startsWith("//") || - trimmed.startsWith("/*") || - trimmed.startsWith("*") - ) { - continue; - } - - const commentIdx = findTrailingCommentIndex(rawLine); - const codePart = commentIdx !== -1 ? rawLine.slice(0, commentIdx) : rawLine; - const commentPart = commentIdx !== -1 ? rawLine.slice(commentIdx) : ""; - - if (codePart.trim().length > 0) { - if (!codePart.trimEnd().endsWith(",")) { - const trimmedCode = codePart.trimEnd(); - const trailingWs = codePart.slice(trimmedCode.length); - lines[i] = `${trimmedCode},${trailingWs}${commentPart}`; - } - return; - } - } -} - -/** - * Options for removing a preset from a schema file. - */ -export type RemovePresetOptions = { - preset: HostPreset; -}; - -/** - * Removes all managed blocks belonging to a base preset ID from an env schema file. - * Fails closed on malformed markers. - * - * @param code The schema file code. - * @param options Removal options. - * @returns The mutation result. - */ -export function removePresetFromSchema( - code: string, - options: RemovePresetOptions, -): { - success: boolean; - updated: boolean; - code?: string; - error?: string; - removedKeys?: string[]; -} { - const { preset } = options; - - // 1. Validate markers - const validation = validateAndFindPresetBlocks(code); - if (!validation.success) { - return { - success: false, - updated: false, - error: validation.error, - }; - } - - const matchingBlocks = validation.blocks.filter((b) => b.baseId === preset); - if (matchingBlocks.length === 0) { - return { - success: true, - updated: false, - code, - removedKeys: [], - }; - } - - const removedKeys = matchingBlocks.flatMap((b) => b.keys); - const lines = code.split(/\r?\n/); - - // Remove blocks in reverse order of line indices - const sortedBlocks = [...matchingBlocks].sort( - (a, b) => b.startLineIndex - a.startLineIndex, - ); - - for (const block of sortedBlocks) { - lines.splice( - block.startLineIndex, - block.endLineIndex - block.startLineIndex + 1, - ); - } - - return { - success: true, - updated: true, - code: lines.join("\n"), - removedKeys, - }; -} - -/** - * Transform an env.ts schema file by merging host preset keys. - * Kept for backwards-compatibility; delegates to managed preset blocks. - * - * @param code The environment configuration code. - * @param preset The selected hosting provider preset. - * @param framework The active framework. - * @param validator The active validator. - * @param targetKeys Optional specific keys to mutate (defaults to all preset keys). - * @returns The result of the mutation operation. - */ -export function mutateEnvConfig( - code: string, - preset: HostPreset, - framework: Framework, - validator: Validator, - targetKeys?: string[], -): { - success: boolean; - updated: boolean; - code?: string; - error?: string; - proposedFields?: Record; -} { - return applyPresetToSchema(code, { - preset, - framework, - validator, - ...(targetKeys !== undefined ? { targetKeys } : {}), - }); -} diff --git a/packages/arkenv/src/features/config-mutation/index.ts b/packages/arkenv/src/features/config-mutation/index.ts deleted file mode 100644 index cbcccd8fb..000000000 --- a/packages/arkenv/src/features/config-mutation/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./config-mutation"; diff --git a/packages/arkenv/src/features/scaffold/frameworks/layouts/codegen.ts b/packages/arkenv/src/features/scaffold/frameworks/layouts/codegen.ts index 2a7f807db..f8c239a5f 100644 --- a/packages/arkenv/src/features/scaffold/frameworks/layouts/codegen.ts +++ b/packages/arkenv/src/features/scaffold/frameworks/layouts/codegen.ts @@ -1,9 +1,8 @@ import type { CodegenFrameworkConfig } from "@/features/scaffold/frameworks/codegen-config"; import { - formatPresetEndMarker, - formatPresetStartMarker, getPresetKeys, type HostPreset, + PRESETS, } from "@/features/scaffold/presets"; import type { Dialect } from "@/features/scaffold/validators/dialects"; @@ -249,12 +248,12 @@ function assembleFlatLayout(params: FieldBuckets): string { ); } + const presetLabel = PRESETS[hostPreset]?.label ?? hostPreset; flatFields = [ ...userFields, ...(userFields.length > 0 ? [""] : []), - `\t${formatPresetStartMarker(hostPreset)}`, + `\t// ${presetLabel} environment variables`, ...presetFieldLines, - `\t${formatPresetEndMarker(hostPreset)}`, ]; } else { const allFields = [...serverFields, ...clientFields, ...sharedFields]; diff --git a/packages/arkenv/src/features/scaffold/presets.ts b/packages/arkenv/src/features/scaffold/presets.ts index 026cda16c..b20496e50 100644 --- a/packages/arkenv/src/features/scaffold/presets.ts +++ b/packages/arkenv/src/features/scaffold/presets.ts @@ -220,40 +220,3 @@ export function mergeEnvKeysWithPreset( } return Array.from(new Set([...(envKeys ?? []), ...presetKeys])); } - -/** - * Marker tags used to delimit machine-managed hosting preset blocks in schema files. - */ -export const PRESET_START_TAG = "@arkenv-preset-start"; -export const PRESET_END_TAG = "@arkenv-preset-end"; - -/** - * Formats a start marker comment for a preset block. - */ -export function formatPresetStartMarker(markerId: string): string { - return `// ${PRESET_START_TAG} ${markerId}`; -} - -/** - * Formats an end marker comment for a preset block. - */ -export function formatPresetEndMarker(markerId: string): string { - return `// ${PRESET_END_TAG} ${markerId}`; -} - -/** - * Parses the marker ID string into base ID and optional role. - */ -export function parseMarkerId(markerId: string): { - baseId: string; - role?: string; -} { - const colonIndex = markerId.indexOf(":"); - if (colonIndex === -1) { - return { baseId: markerId }; - } - return { - baseId: markerId.slice(0, colonIndex), - role: markerId.slice(colonIndex + 1), - }; -} diff --git a/packages/arkenv/src/features/scaffold/validators.test.ts b/packages/arkenv/src/features/scaffold/validators.test.ts index 53fa7330d..02dd145f4 100644 --- a/packages/arkenv/src/features/scaffold/validators.test.ts +++ b/packages/arkenv/src/features/scaffold/validators.test.ts @@ -341,13 +341,12 @@ describe("validators templates", () => { hostPreset: "vercel" as const, }; const template = getSimpleTemplate(options); - expect(template).toContain("// @arkenv-preset-start vercel"); + expect(template).toContain("// Vercel environment variables"); expect(template).toContain('VERCEL: "string?"'); expect(template).toContain( "VERCEL_ENV: \"'production' | 'preview' | 'development'?\"", ); expect(template).toContain('VERCEL_URL: "string?"'); - expect(template).toContain("// @arkenv-preset-end vercel"); }); it("includes Vercel preset with Zod validator for Next.js", () => { @@ -359,7 +358,7 @@ describe("validators templates", () => { hostPreset: "vercel" as const, }; const template = getSimpleTemplate(options); - expect(template).toContain("// @arkenv-preset-start vercel"); + expect(template).toContain("// Vercel environment variables"); expect(template).toContain("VERCEL: z.string().optional()"); expect(template).toContain( 'VERCEL_ENV: z.enum(["production", "preview", "development"]).optional()', @@ -370,7 +369,6 @@ describe("validators templates", () => { expect(template).toContain( "NEXT_PUBLIC_VERCEL_URL: z.string().optional()", ); - expect(template).toContain("// @arkenv-preset-end vercel"); }); it("prefixes Vite client keys via framework clientPrefix", () => { diff --git a/packages/arkenv/src/features/scaffold/validators/assemble-simple.ts b/packages/arkenv/src/features/scaffold/validators/assemble-simple.ts index 19ae376fd..cf5b88e59 100644 --- a/packages/arkenv/src/features/scaffold/validators/assemble-simple.ts +++ b/packages/arkenv/src/features/scaffold/validators/assemble-simple.ts @@ -1,10 +1,6 @@ import { getCodegenConfig } from "@/features/scaffold/frameworks/codegen-config"; import { assembleCodegenTemplate } from "@/features/scaffold/frameworks/layouts"; -import { - formatPresetEndMarker, - formatPresetStartMarker, - getPresetKeys, -} from "@/features/scaffold/presets"; +import { getPresetKeys, PRESETS } from "@/features/scaffold/presets"; import type { ScaffoldContext } from "@/features/scaffold/scaffold-context"; import type { Dialect } from "./dialects"; @@ -56,8 +52,9 @@ export function assembleSimpleFromDialect( context.hostPreset !== "none" && presetKeys.length > 0 ) { - const startMarker = `\t\t${formatPresetStartMarker(context.hostPreset)}`; - const endMarker = `\t\t${formatPresetEndMarker(context.hostPreset)}`; + const presetLabel = + PRESETS[context.hostPreset]?.label ?? context.hostPreset; + const presetComment = `\t\t// ${presetLabel} environment variables`; const presetFields = dialect.formatSimpleSchemaFields( presetKeys, context.clientPrefix, @@ -70,9 +67,9 @@ export function assembleSimpleFromDialect( context.clientPrefix, undefined, ); - schemaFields = `${userFields}\n\n${startMarker}\n${presetFields}\n${endMarker}`; + schemaFields = `${userFields}\n\n${presetComment}\n${presetFields}`; } else { - schemaFields = `${dialect.defaultSimpleSchemaFields}\n\n${startMarker}\n${presetFields}\n${endMarker}`; + schemaFields = `${dialect.defaultSimpleSchemaFields}\n\n${presetComment}\n${presetFields}`; } } else if (keys.length > 0) { schemaFields = dialect.formatSimpleSchemaFields( diff --git a/skills/arkenv/SKILL.md b/skills/arkenv/SKILL.md index 75b5fd5a2..fd5deb758 100644 --- a/skills/arkenv/SKILL.md +++ b/skills/arkenv/SKILL.md @@ -34,34 +34,11 @@ In v1, ArkEnv offers two first-class validation engines: - Initialize ArkEnv in new or existing projects using `pnpm dlx arkenv init` (or `npx arkenv init`). - Automatically detect frameworks (`Next.js`, `Nuxt`, `Vite`, `Bun`, etc.) and scaffold `env.ts`. - Select hosting provider preset during init (`--preset, -P ` or `--host-preset, -H `). -- Apply or refresh hosting presets on Day 2 via `arkenv preset apply ` without overwriting custom user schema fields. -- Remove hosting presets on Day 2 via `arkenv preset remove `. - Automatically configure `tsconfig.json` and schema configuration pointers in `package.json`. -### Managed Preset Blocks & Day 2 Management +### Hosting presets -ArkEnv uses machine-managed comment blocks to isolate hosting provider variables (e.g. Vercel, Netlify, Cloudflare, Railway, Render, Fly) from user-defined environment variables: - -```ts -export const env = arkenv({ - DATABASE_URL: "string", // User-owned field (outside markers) - - // @arkenv-preset-start vercel - VERCEL: "string?", - VERCEL_ENV: "'production' | 'preview' | 'development'?", - VERCEL_URL: "string?", - // @arkenv-preset-end vercel -}); -``` - -#### Safe Refresh & Collision Handling -- **User-Owned Space**: Everything outside `@arkenv-preset-start/end` markers is strictly user-owned. -- **Fail-Closed on Collision**: If a preset tries to add a key that already exists outside managed blocks (unmarked) or inside another preset's block, the CLI fails closed with an actionable collision error rather than silently overwriting. -- **Nuke-and-Pave Refresh**: When re-running `arkenv preset apply `, the CLI safely replaces only the contents inside the matching preset markers, preserving user fields and formatting. -- **Malformed Marker Safety**: If markers are unclosed, mismatched, or nested, the CLI aborts without modifying files. -- **`.env.example` Sync**: - - `preset apply` only appends missing keys if `.env.example` already exists on disk (never creates `.env`). - - `preset remove` removes preset keys from `.env.example` only if no remaining presets in the schema use them. +ArkEnv is **code-first**. You can select a hosting provider preset during initial project setup (`--preset vercel`, `netlify`, `cloudflare`, `railway`, `render`, `fly`) or copy-paste provider fields directly into `./env.ts` from the documentation (`/docs/core-concepts/hosting-presets`). There are no machine-managed comment blocks or CLI mutation commands. ### Agent setup (machine-readable) @@ -90,7 +67,7 @@ AI agents SHOULD always use the CLI for project initialization to ensure consist - **`code`**: a stable identifier you can branch on. Refusal codes: `REQUIREMENTS_NOT_MET`, `GIT_TREE_DIRTY`, `NON_EMPTY_DIR`. A `code` of `INTERNAL` means the CLI *broke* rather than *refused* - retrying with flags will not help. - **`retryWith`**: the flag(s) that would bypass the check (e.g. `["--force"]`). Empty (`[]`) means the refusal is not bypassable. -**Escalation pattern**: always run `init --agent` or `preset apply --agent` **without** `--force` first. If you get `status: "error"`, inspect `code` and `retryWith`. Only re-run with the flag(s) from `retryWith` (e.g. append `--force`) once you have deliberately decided the refusal is safe to bypass - do not add `--force` pre-emptively. +**Escalation pattern**: always run `init --agent` **without** `--force` first. If you get `status: "error"`, inspect `code` and `retryWith`. Only re-run with the flag(s) from `retryWith` (e.g. append `--force`) once you have deliberately decided the refusal is safe to bypass - do not add `--force` pre-emptively. --- @@ -107,30 +84,20 @@ pnpm dlx arkenv init [options] #### Options: - `--preset, -P `: Specify hosting provider preset (none, vercel, netlify, cloudflare, railway, render, fly). - `--no-codegen`: Disable Next.js codegen configuration setup. - -### `preset apply` - -Apply or refresh a hosting provider preset into an existing ArkEnv schema using managed comment blocks. - -```bash -pnpm arkenv preset apply [options] -``` - -#### Options: -- `--file `: Path to schema file or directory (overrides `package.json` `"arkenv"` pointer). - `--force, -f`: Bypass clean git working tree safety check. -### `preset remove` +### `check` -Safely remove a hosting provider preset and its managed block from schema files and `.env.example`. +Validate the environment against your schema file. ```bash -pnpm arkenv preset remove [options] +pnpm arkenv check [options] ``` #### Options: -- `--file `: Path to schema file or directory (overrides `package.json` `"arkenv"` pointer). -- `--force, -f`: Bypass clean git working tree safety check. +- `--verify-example [file]`: Verify that all declared schema keys are present in `.env.example` (or a custom example file path) without mutating files. +- `--env-file `: Specify one or more custom environment files to load. +- `--json`: Output structured JSON diagnostics to stdout. ---