Skip to content

feat(cli): add [auth.smtp] and [deployments] sections to config-as-code#123

Merged
tonychang04 merged 7 commits into
mainfrom
feat/insforge-toml-smtp
May 13, 2026
Merged

feat(cli): add [auth.smtp] and [deployments] sections to config-as-code#123
tonychang04 merged 7 commits into
mainfrom
feat/insforge-toml-smtp

Conversation

@tonychang04
Copy link
Copy Markdown
Contributor

@tonychang04 tonychang04 commented May 12, 2026

Summary

Step 2 of the SMTP config-as-code initiative. Adds [auth.smtp] to the declarative TOML surface with secret-aware password handling.

Companion PR: InsForge/InsForge#1258 (backend — exposes smtpConfig in admin /api/metadata). Required for this CLI feature to work — without it, config apply correctly puts SMTP changes in `skipped[]` per the capability gate.

What ships

Schema

Capability gate

  • metadataSupports() extended to probe raw.auth.smtpConfig presence
  • SMTP changes against an older backend (no smtpConfig in metadata) land in skipped[] with the upgrade message; no PUT issued — same protocol as redirect URLs

env() ref resolution

  • New resolveEnvRef(envRef, fieldPath) in src/lib/config-secrets.ts
  • Calls /api/secrets/:name pre-flight before any PUT — missing/inactive secrets fail fast with actionable error (SECRET_NOT_FOUND / SECRET_EMPTY) naming the exact secrets add command
  • Resolution source is InsForge secrets store only (not local env vars) — secrets are shared per-project across teammates and CI

Diff layer

  • DiffChange discriminated union: auth (redirect URLs) | auth.smtp (whole-object upsert)
  • Force-resend password semantics: when TOML carries password = \"env(NAME)\", every `apply` resolves the current secrets-store value and re-sends. Reason: backend ciphertext can't be diffed against client-side, and re-sending matches user mental model ("I just rotated SMTP_PASSWORD, re-apply should propagate it")
  • Password slot in plan output is always an opaque marker (`(set)`, `(unset)`, `env(NAME)`, `(unchanged)`) — actual values never appear

Export

  • When backend reports `hasPassword: true`, emits `password = "env(SMTP_PASSWORD)"` as a deterministic placeholder + discovery comment (`# password is managed via secrets — run \`insforge secrets add SMTP_PASSWORD \``) so the workflow is learnable from a fresh export without reading skill docs
  • When `hasPassword: false`, omits the password line entirely

Apply

  • New variant in `applyChange()` for `auth.smtp`: builds the upsert body, resolves env() ref pre-flight, PUTs `/api/auth/smtp-config`
  • Omitting `password` from the TOML omits it from the PUT body — backend's upsert preserves the existing encrypted value (matches our "absent = preserve" semantics)

Version bump

0.1.75 → 0.1.76

Tests

  • 51 config-flow unit tests pass (was 38 pre-SMTP)
  • New coverage: SMTP schema parsing + literal-password rejection, TOML roundtrip with placeholder, diff force-resend semantics, env() resolution failure modes (404 / empty / non-200), apply path with secrets-store mock, capability skip when backend predates the field
  • Build clean (`npm run build`)
  • Lint clean (`npx eslint src/` — 0 errors; 39 pre-existing warnings unrelated)

Test plan

  • Unit tests cover schema, capability gate, diff, env() resolution, apply, export
  • E2E against local backend with #1258 (deferred — docker not available locally this session; recommend post-merge of #1258, before tagging the npm release)
  • Run `config apply` against a cloud project once #1258 is deployed

Design notes captured in code

The trickier decisions are documented inline:

  • Force-resend rationale: `src/lib/config-diff.ts` `diffSmtp()`
  • env() resolution from InsForge secrets store (not local env): `src/lib/config-secrets.ts` `resolveEnvRef()` JSDoc
  • Default-keep vs explicit-set for password: `src/commands/config/apply.ts` `applyChange()` comment
  • Why password is never round-tripped on export: `src/commands/config/export.ts` SMTP block

Followups (out of scope here)

  • Skills PR: re-introduce the SMTP TOML example we trimmed last session — now it actually works. Document the placeholder convention, `hasPassword` round-trip, and force-resend plan output.
  • E2E after #1258 is deployed.

🤖 Generated with Claude Code


Summary by cubic

Adds [auth.smtp] and [deployments] subdomain to insforge.toml with secure env(NAME) password handling, export/apply wiring, and capability gates. Also tightens validation and improves export hints.

  • New Features

    • Schema: [auth.smtp] with enabled, host, port (1–65535), username, password (must be env(NAME)), sender_email, sender_name, min_interval_seconds; [deployments] with subdomain (string or null; "" clears on apply).
    • Apply: SMTP → PUT /api/auth/smtp-config (omit password to preserve; password = "env(NAME)" resolves via secrets store and force-resends); Deployments → PUT /api/deployments/slug with { slug: "name" | null }.
    • Export: SMTP emits password = "env(SMTP_PASSWORD)" when a password exists, with a discovery comment that names the actual secret in the file; Deployments emits only when a slug is set.
    • Capability gate: applies/exports only when /api/metadata exposes auth.smtpConfig and deployments; otherwise entries are added to skipped[].
  • Bug Fixes

    • Secret lookup recovers missing-secret signals from ossFetch-thrown errors so missing secrets surface as SECRET_NOT_FOUND with an insforge secrets add NAME "<value>" hint.
    • Export typeof-guards auth before checking smtpConfig to avoid a crash on malformed metadata.
    • Capability checks add an exhaustiveness guard in metadataSupports to prevent silently skipping future sections.

Written for commit 6d6eee4. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Add SMTP support in config: export and apply flows, SMTP-specific diffs/formatting, and deployments subdomain handling; password fields use env-secret references and are preserved when omitted.
  • Tests
    • Expanded coverage for SMTP behavior, secret resolution, exports, apply flows, and deployments subdomain logic.
  • Chores
    • Bumped package version to 0.1.77.

Review Change Stack

Note

Add [auth.smtp] and [deployments] sections to config-as-code export and apply

  • Extends the TOML schema, diff, export, and apply pipeline to support two new config sections: [auth.smtp] and [deployments].
  • Export writes [auth.smtp] fields with an env(SMTP_PASSWORD) placeholder when a password exists server-side, and writes [deployments] when a non-empty custom subdomain is set.
  • Apply resolves env() secret references via GET /api/secrets/:name before issuing PUT /api/auth/smtp-config, failing fast with actionable errors if the secret is missing or empty. Deployment subdomain changes are sent via PUT /api/deployments/slug, including null to clear.
  • Both sections are skipped with a named reason when the backend metadata does not expose the corresponding capability.
  • Behavioral Change: literal passwords in TOML are now rejected at parse time; only env() references are accepted for the SMTP password field.

Changes since #123 opened

  • Tightened SMTP port validation to require values within the 1-65535 range [6d6eee4]
  • Modified SMTP password comment generation to use actual environment variable reference name [6d6eee4]
  • Added exhaustiveness check for DiffChange variants in config-capabilities [6d6eee4]

Macroscope summarized 35af5b9.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds auth.smtp and deployments.subdomain handling across validation, TOML I/O, diffing/formatting, secret resolution, export/apply commands, and tests; bumps package version.

Changes

SMTP & deployments config-as-code

Layer / File(s) Summary
Types, schema, and capability detection
src/lib/config-schema.ts, src/lib/config-capabilities.ts, src/lib/config-capabilities.test.ts
Adds SmtpConfig and DeploymentsConfig; extends AuthConfig/InsforgeConfig; updates metadataSupports/changePath and tests to detect auth.smtp and deployments.subdomain.
TOML parse/stringify and tests
src/lib/config-toml.ts, src/lib/config-toml.test.ts
parseConfigToml validates [auth.smtp] (password as env(...) only) and [deployments]; stringifyConfigToml emits deterministic [auth.smtp] with secrets discovery comments; tests cover parsing, stringify, and round-trip.
Secret resolution (resolveEnvRef)
src/lib/config-secrets.ts, src/lib/config-secrets.test.ts
New resolveEnvRef(envRef, fieldPath) parses env(NAME), GETs /api/secrets/<NAME> via ossFetch, maps 404/empty/other failures to CLIError codes with hints, and returns the secret; tests exercise success and error modes.
Diffing & formatting for auth.smtp
src/lib/config-diff.ts, src/lib/config-diff.test.ts, src/lib/config-format.ts
Adds LiveConfig, SmtpDiffView, LiveSmtpState; diffSmtp emits auth.smtp modify changes (with optional passwordEnvRef for force-resend); formatChange renders SMTP diffs as multi-line blocks; tests for no-op, force-resend, and combined diffs.
Export command: metadata probing and SMTP export
src/commands/config/export.ts, src/commands/config/export.test.ts
registerConfigExportCommand reads RawMetadataResponse, conditionally emits auth.allowed_redirect_urls, auth.smtp (sets password to env(SMTP_PASSWORD) when hasPassword), and deployments.subdomain; records skipped sections for older backends; tests updated.
Apply command: liveFromMetadata and applyChange handling
src/commands/config/apply.ts, src/commands/config/apply.test.ts
registerConfigApplyCommand builds LiveConfig from metadata; applyChange handles auth.smtp by resolving password via resolveEnvRef when passwordEnvRef is present, omitting password otherwise, and PUTting to /api/auth/smtp-config; tests cover secret resolution, missing-secret errors, omission, capability gating, and deployments.subdomain PUT behavior.
Misc tests & package bump
src/commands/..., src/lib/..., package.json
Various test updates across apply/export/diff/toml/secrets and package version bumped to 0.1.77.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • InsForge/CLI#109: Main PR extends the same insforge config export/plan/apply MVP by evolving config-diff/capabilities/schema/toml and commands to support extended config slices.

Suggested reviewers

  • jwfing

Poem

🐰 I hopped through TOML, env, and test,
Fetching secrets so the mail can rest.
Diffed the smtp, saved the subdomain,
Bumped the version — off I hop again.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.48% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately summarizes the main feature: adding two new configuration sections ([auth.smtp] and [deployments]) to the config-as-code system.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/insforge-toml-smtp

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/commands/config/apply.ts (1)

102-113: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preflight SMTP secret resolution before applying any change to prevent partial applies.

With mixed batches, auth.allowed_redirect_urls can be PUT first, then fail on SMTP secret resolution later. That leaves the run partially applied.

Suggested direction
+// 1) Pre-resolve all secret refs for planned SMTP changes before any PUT.
+const resolvedSmtpPasswords = new Map<DiffChange, string>();
+for (const change of result.changes) {
+  if (change.section === 'auth.smtp' && change.passwordEnvRef) {
+    const value = await resolveEnvRef(`env(${change.passwordEnvRef})`, 'auth.smtp.password');
+    resolvedSmtpPasswords.set(change, value);
+  }
+}
+
 // 2) Apply changes only after preflight succeeds.
 for (const change of result.changes) {
   ...
-  await applyChange(change);
+  await applyChange(change, resolvedSmtpPasswords.get(change));
   applied.push(change);
 }

And update applyChange to accept an optional pre-resolved password and avoid resolving during mutation.

Also applies to: 186-193

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/config/apply.ts` around lines 102 - 113, Preflight resolution of
SMTP secrets for every change before any mutation: iterate result.changes and
for each change whose path may require secrets (e.g., SMTP/password fields)
resolve the secret ahead of time and collect failures into skipped so no partial
applies occur; then call applyChange(change, preResolvedSecret?) instead of
resolving inside applyChange. Update the applyChange function signature to
accept an optional preResolvedPassword (or credentials object) and ensure it
uses that value if provided and does not attempt resolution during the mutation.
Apply the same preflight logic to the second batch processing block that also
iterates result.changes (the other loop around applyChange) so both places
resolve SMTP secrets before making any PUTs and mark failing resolutions as
skipped.
🧹 Nitpick comments (1)
src/lib/config-secrets.test.ts (1)

83-137: ⚡ Quick win

Add a malformed-JSON lookup test for resolveEnvRef.

You’re asserting major failure modes already; adding a 200 + invalid JSON case will lock in stable error behavior for backend/proxy anomalies.

Proposed test case
 describe('resolveEnvRef', () => {
+  it('throws SECRET_LOOKUP_FAILED when 200 response is not valid JSON', async () => {
+    ossFetchMock.mockResolvedValueOnce(new Response('not-json', { status: 200 }));
+    await expect(
+      resolveEnvRef('env(SMTP_PASSWORD)', 'auth.smtp.password'),
+    ).rejects.toMatchObject({ code: 'SECRET_LOOKUP_FAILED' });
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/config-secrets.test.ts` around lines 83 - 137, Add a test inside the
describe('resolveEnvRef') block that simulates a 200 response with malformed
JSON from ossFetchMock (e.g., ossFetchMock.mockResolvedValueOnce(new
Response('invalid', { status: 200 })) ) when calling
resolveEnvRef('env(BROKEN)', 'auth.smtp.password') and assert it rejects with
the same error shape used for HTTP lookup failures (e.g., toMatchObject({ code:
'SECRET_LOOKUP_FAILED' })); reference resolveEnvRef, ossFetchMock and the
existing SECRET_LOOKUP_FAILED assertions to mirror the error expectation and
placement alongside the other cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/config/export.ts`:
- Around line 83-84: The guard using "'smtpConfig' in authSlice" can throw if
authSlice is not an object; update the conditional in export command (the block
referencing authSlice and smtpConfig) to first verify authSlice is a non-null
object (e.g., typeof authSlice === 'object' && authSlice !== null) before using
the 'in' operator and accessing authSlice.smtpConfig, so malformed metadata
yields a controlled CLI error path rather than a runtime TypeError.

In `@src/lib/config-format.ts`:
- Around line 51-53: The note rendering incorrectly nests env(...) when
c.passwordEnvRef already includes env(...) — normalize c.passwordEnvRef before
pushing the line: check and strip any surrounding "env(" and trailing ")" (or
detect if it already starts with "env(") so you always produce a single env(...)
wrapper; update the code around the lines.push call that references
c.passwordEnvRef to use the normalized value (e.g., compute a local
normalizedPasswordRef and then use lines.push(`    (password force-resent from
env(${normalizedPasswordRef}))`)) so duplicate env(...) is avoided.

In `@src/lib/config-schema.ts`:
- Around line 113-118: The SMTP port validation currently only checks for
integer type in the block handling 'port' and allows out-of-range values; update
the check in the same conditional (the code that assigns out.port) to validate
that obj.port is an integer within the TCP port range 1..65535 and throw
ConfigValidationError('auth.smtp.port', '<message>') when it's not; keep the
existing type/integer validation but extend it to enforce the range before
assigning out.port so invalid values like -1 or 70000 are rejected.

In `@src/lib/config-secrets.ts`:
- Around line 130-131: The JSON parsing for the secret response is unguarded:
wrap the call to res.json() in a try/catch and validate HTTP status before
trusting the body so parsing errors or non-2xx responses produce a consistent
CLIError; specifically, in the function that handles the fetch response (the
block using res and const body = (await res.json()) as { value?: string }),
first check res.ok and throw a CLIError with context if not ok, then try to
parse res.json() inside a try/catch and on any exception or if body.value is not
a non-empty string throw a CLIError describing the failure to retrieve/parse the
secret (include res.status/res.statusText or the caught error.message for
debugging).

In `@src/lib/config-toml.ts`:
- Around line 56-61: The emitted guidance hardcodes "SMTP_PASSWORD" even though
smtp.password contains the actual env reference; update the logic that builds
the comment (where lines.push is called for smtp.password in config-toml.ts) to
extract the secret/env name from smtp.password (the env(...) string) and
interpolate that name into the comment instead of the literal SMTP_PASSWORD,
falling back to a generic message if extraction fails. Ensure you reference and
parse smtp.password (the env(...) value) to compute the secret name used in the
comment and then push the comment using that extracted name.

---

Outside diff comments:
In `@src/commands/config/apply.ts`:
- Around line 102-113: Preflight resolution of SMTP secrets for every change
before any mutation: iterate result.changes and for each change whose path may
require secrets (e.g., SMTP/password fields) resolve the secret ahead of time
and collect failures into skipped so no partial applies occur; then call
applyChange(change, preResolvedSecret?) instead of resolving inside applyChange.
Update the applyChange function signature to accept an optional
preResolvedPassword (or credentials object) and ensure it uses that value if
provided and does not attempt resolution during the mutation. Apply the same
preflight logic to the second batch processing block that also iterates
result.changes (the other loop around applyChange) so both places resolve SMTP
secrets before making any PUTs and mark failing resolutions as skipped.

---

Nitpick comments:
In `@src/lib/config-secrets.test.ts`:
- Around line 83-137: Add a test inside the describe('resolveEnvRef') block that
simulates a 200 response with malformed JSON from ossFetchMock (e.g.,
ossFetchMock.mockResolvedValueOnce(new Response('invalid', { status: 200 })) )
when calling resolveEnvRef('env(BROKEN)', 'auth.smtp.password') and assert it
rejects with the same error shape used for HTTP lookup failures (e.g.,
toMatchObject({ code: 'SECRET_LOOKUP_FAILED' })); reference resolveEnvRef,
ossFetchMock and the existing SECRET_LOOKUP_FAILED assertions to mirror the
error expectation and placement alongside the other cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c19f54ee-fe1e-4af7-91cf-bbe4d02adda8

📥 Commits

Reviewing files that changed from the base of the PR and between 5d3c306 and 449e49d.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (14)
  • package.json
  • src/commands/config/apply.test.ts
  • src/commands/config/apply.ts
  • src/commands/config/export.test.ts
  • src/commands/config/export.ts
  • src/lib/config-capabilities.ts
  • src/lib/config-diff.test.ts
  • src/lib/config-diff.ts
  • src/lib/config-format.ts
  • src/lib/config-schema.ts
  • src/lib/config-secrets.test.ts
  • src/lib/config-secrets.ts
  • src/lib/config-toml.test.ts
  • src/lib/config-toml.ts

Comment thread src/commands/config/export.ts Outdated
Comment thread src/lib/config-format.ts
Comment on lines +51 to +53
if (c.passwordEnvRef) {
lines.push(` (password force-resent from env(${c.passwordEnvRef}))`);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize passwordEnvRef before rendering the force-resend note.

Current formatting can produce env(env(...)) depending on what passwordEnvRef carries.

Proposed fix
     if (c.passwordEnvRef) {
-      lines.push(`    (password force-resent from env(${c.passwordEnvRef}))`);
+      const ref = c.passwordEnvRef.startsWith('env(')
+        ? c.passwordEnvRef
+        : `env(${c.passwordEnvRef})`;
+      lines.push(`    (password force-resent from ${ref})`);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/config-format.ts` around lines 51 - 53, The note rendering
incorrectly nests env(...) when c.passwordEnvRef already includes env(...) —
normalize c.passwordEnvRef before pushing the line: check and strip any
surrounding "env(" and trailing ")" (or detect if it already starts with "env(")
so you always produce a single env(...) wrapper; update the code around the
lines.push call that references c.passwordEnvRef to use the normalized value
(e.g., compute a local normalizedPasswordRef and then use lines.push(`   
(password force-resent from env(${normalizedPasswordRef}))`)) so duplicate
env(...) is avoided.

Comment thread src/lib/config-schema.ts
Comment thread src/lib/config-secrets.ts
Comment on lines +130 to +131
const body = (await res.json()) as { value?: string };
if (typeof body.value !== 'string' || body.value.length === 0) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard JSON parsing for successful secret lookups.

A 2xx response with invalid/empty JSON will currently throw an untyped parse error instead of a consistent CLIError, which makes config apply failure handling brittle.

Proposed fix
-  const body = (await res.json()) as { value?: string };
+  let body: { value?: string };
+  try {
+    body = (await res.json()) as { value?: string };
+  } catch {
+    throw new CLIError(
+      `failed to resolve env(${secretName}) for ${fieldPath}: invalid response body`,
+      1,
+      'SECRET_LOOKUP_FAILED',
+    );
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const body = (await res.json()) as { value?: string };
if (typeof body.value !== 'string' || body.value.length === 0) {
let body: { value?: string };
try {
body = (await res.json()) as { value?: string };
} catch {
throw new CLIError(
`failed to resolve env(${secretName}) for ${fieldPath}: invalid response body`,
1,
'SECRET_LOOKUP_FAILED',
);
}
if (typeof body.value !== 'string' || body.value.length === 0) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/config-secrets.ts` around lines 130 - 131, The JSON parsing for the
secret response is unguarded: wrap the call to res.json() in a try/catch and
validate HTTP status before trusting the body so parsing errors or non-2xx
responses produce a consistent CLIError; specifically, in the function that
handles the fetch response (the block using res and const body = (await
res.json()) as { value?: string }), first check res.ok and throw a CLIError with
context if not ok, then try to parse res.json() inside a try/catch and on any
exception or if body.value is not a non-empty string throw a CLIError describing
the failure to retrieve/parse the secret (include res.status/res.statusText or
the caught error.message for debugging).

Comment thread src/lib/config-toml.ts
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 15 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/commands/config/export.ts">

<violation number="1" location="src/commands/config/export.ts:83">
P2: Guard `authSlice` as an object before using the `in` operator to avoid runtime `TypeError` on malformed metadata responses.</violation>
</file>

<file name="src/lib/config-secrets.ts">

<violation number="1" location="src/lib/config-secrets.ts:102">
P1: `resolveEnvRef` relies on `ossFetch` returning 404 responses, but `ossFetch` throws on non-2xx, so the 404/`!res.ok` branches here are unreachable and missing secrets are misclassified as `SECRET_LOOKUP_FAILED`.</violation>
</file>

<file name="src/lib/config-toml.ts">

<violation number="1" location="src/lib/config-toml.ts:60">
P3: Avoid hardcoding `SMTP_PASSWORD` in the emitted guidance so instructions don't conflict with a custom `env(...)` secret name.</violation>
</file>

<file name="src/lib/config-schema.ts">

<violation number="1" location="src/lib/config-schema.ts:114">
P2: Validate `auth.smtp.port` against the valid TCP range (1-65535); integer-only validation currently allows invalid ports like -1 and 70000.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

Comment thread src/lib/config-secrets.ts
Comment thread src/commands/config/export.ts Outdated
Comment thread src/lib/config-schema.ts Outdated
Comment thread src/lib/config-toml.ts Outdated
ossFetch throws on any non-2xx response, swallowing the status code. The
resolveEnvRef path that previously checked res.status === 404 never fired
— missing secrets always landed in SECRET_LOOKUP_FAILED.

Recover the signal from the thrown error's message instead (the backend's
NOT_FOUND path uses "Secret not found: NAME" wording). Verified against
live local backend on the SMTP branch:

  $ insforge config apply  # with env(NONEXISTENT) in TOML
  code: SECRET_NOT_FOUND
  msg: auth.smtp.password references env(NONEXISTENT) but no such secret
       exists.
    fix: insforge secrets add NONEXISTENT "<value>"

Unit tests updated to mock ossFetch as throwing (matches real behavior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/commands/config/apply.test.ts (1)

24-29: ⚡ Quick win

Make missing-secret mock error shape closer to real ossFetch failures.

The mocked throw message (Secret not found: ...) is a bit synthetic compared to non-2xx throw paths. Aligning it with the real thrown format (including 404 signal) will better guard against regressions in resolveEnvRef error parsing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/config/apply.test.ts` around lines 24 - 29, The mock in
src/commands/config/apply.test.ts should throw an error shaped like real
ossFetch non-2xx failures so resolveEnvRef can parse the 404 signal; change the
thrown Error in the test (currently "Secret not found: ${key}") to include the
HTTP status indicator (e.g. include "404" or "status: 404" and a Not Found
phrase) so error-parsing paths in resolveEnvRef and any ossFetch-related logic
will behave the same as real failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/config/apply.test.ts`:
- Around line 93-97: The shared test state variable nextMetadataResponse is not
reset between tests; update the beforeEach block (the one that calls
vi.clearAllMocks(), secretsStore.clear(), and sets tmp) to also reset
nextMetadataResponse (e.g. set to undefined/null or its initial value) so each
test starts with a clean metadata state and cannot inherit prior test responses.

In `@src/lib/config-secrets.test.ts`:
- Around line 124-127: The test in src/lib/config-secrets.test.ts that currently
does ossFetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 }))
should instead mock the fetch as a rejected promise to mirror runtime behavior;
replace that resolved Response with ossFetchMock.mockRejectedValueOnce(new
Error('boom')) (or an Error-like rejection) so the test exercising the
SECRET_LOOKUP_FAILED mapping uses a thrown error like the real ossFetch and
accurately reflects the code paths in the functions that handle ossFetch
failures.

---

Nitpick comments:
In `@src/commands/config/apply.test.ts`:
- Around line 24-29: The mock in src/commands/config/apply.test.ts should throw
an error shaped like real ossFetch non-2xx failures so resolveEnvRef can parse
the 404 signal; change the thrown Error in the test (currently "Secret not
found: ${key}") to include the HTTP status indicator (e.g. include "404" or
"status: 404" and a Not Found phrase) so error-parsing paths in resolveEnvRef
and any ossFetch-related logic will behave the same as real failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 715005df-00aa-491b-9ed3-213fc682fdd2

📥 Commits

Reviewing files that changed from the base of the PR and between 449e49d and b30bd9c.

📒 Files selected for processing (3)
  • src/commands/config/apply.test.ts
  • src/lib/config-secrets.test.ts
  • src/lib/config-secrets.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/config-secrets.ts

Comment on lines 93 to 97
beforeEach(() => {
vi.clearAllMocks();
secretsStore.clear();
tmp = mkdtempSync(join(tmpdir(), 'insforge-apply-test-'));
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reset nextMetadataResponse in beforeEach to prevent cross-test coupling.

nextMetadataResponse is shared mutable state but isn’t reset per test. Add a reset in beforeEach so future tests can’t accidentally inherit previous metadata.

Suggested patch
 beforeEach(() => {
   vi.clearAllMocks();
   secretsStore.clear();
+  nextMetadataResponse = {};
   tmp = mkdtempSync(join(tmpdir(), 'insforge-apply-test-'));
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
beforeEach(() => {
vi.clearAllMocks();
secretsStore.clear();
tmp = mkdtempSync(join(tmpdir(), 'insforge-apply-test-'));
});
beforeEach(() => {
vi.clearAllMocks();
secretsStore.clear();
nextMetadataResponse = {};
tmp = mkdtempSync(join(tmpdir(), 'insforge-apply-test-'));
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/config/apply.test.ts` around lines 93 - 97, The shared test
state variable nextMetadataResponse is not reset between tests; update the
beforeEach block (the one that calls vi.clearAllMocks(), secretsStore.clear(),
and sets tmp) to also reset nextMetadataResponse (e.g. set to undefined/null or
its initial value) so each test starts with a clean metadata state and cannot
inherit prior test responses.

Comment on lines +124 to +127
it('throws SECRET_LOOKUP_FAILED on a non-404 HTTP error', async () => {
ossFetchMock.mockResolvedValueOnce(
new Response('boom', { status: 500 }),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mock non-404 failures as rejected promises to mirror runtime behavior.

This test currently simulates HTTP 500 via a resolved Response, but runtime behavior (per this PR’s own contract) is that ossFetch throws on non-2xx. Keeping this as a rejection makes the test representative and avoids false confidence in SECRET_LOOKUP_FAILED mapping.

Suggested test adjustment
   it('throws SECRET_LOOKUP_FAILED on a non-404 HTTP error', async () => {
-    ossFetchMock.mockResolvedValueOnce(
-      new Response('boom', { status: 500 }),
-    );
+    ossFetchMock.mockRejectedValueOnce(
+      new Error('Request failed with status 500'),
+    );
     await expect(
       resolveEnvRef('env(WHATEVER)', 'auth.smtp.password'),
     ).rejects.toMatchObject({ code: 'SECRET_LOOKUP_FAILED' });
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('throws SECRET_LOOKUP_FAILED on a non-404 HTTP error', async () => {
ossFetchMock.mockResolvedValueOnce(
new Response('boom', { status: 500 }),
);
it('throws SECRET_LOOKUP_FAILED on a non-404 HTTP error', async () => {
ossFetchMock.mockRejectedValueOnce(
new Error('Request failed with status 500'),
);
await expect(
resolveEnvRef('env(WHATEVER)', 'auth.smtp.password'),
).rejects.toMatchObject({ code: 'SECRET_LOOKUP_FAILED' });
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/config-secrets.test.ts` around lines 124 - 127, The test in
src/lib/config-secrets.test.ts that currently does
ossFetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 })) should
instead mock the fetch as a rejected promise to mirror runtime behavior; replace
that resolved Response with ossFetchMock.mockRejectedValueOnce(new
Error('boom')) (or an Error-like rejection) so the test exercising the
SECRET_LOOKUP_FAILED mapping uses a thrown error like the real ossFetch and
accurately reflects the code paths in the functions that handle ossFetch
failures.

Mirrors the existing guard on the allowed_redirect_urls branch — without
it, a malformed metadata response (e.g. auth: "x") would TypeError on
'smtpConfig' in authSlice instead of cleanly landing in skipped[].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@jwfing jwfing left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — feat(cli): add [auth.smtp] section to config-as-code with env() password resolution

Summary: Solid, well-scoped addition of SMTP config-as-code. The security model (env()-only passwords, opaque plan output, pre-flight secret resolution, no plaintext in logs) is correct and the test coverage is thorough. Four non-blocking items below.

Requirements context: No /docs/superpowers/ directory exists in this repo — assessing against the PR description (Step 2 of SMTP config-as-code initiative) and companion PR InsForge/InsForge#1258 as the stated requirements. The implementation matches the spec described in the PR body on all major points: schema, capability gate, env() resolution, diff/force-resend semantics, export placeholder, and apply upsert path.


Findings

Critical

(none)


Suggestion

[Software Engineering / Functionality] Port range not validated — src/lib/config-schema.ts:113–118

validateSmtp accepts any integer for port (including 0, negatives, or values > 65535). Compare the existing non-negative guard on min_interval_seconds at line 151–162 — the same pattern should apply here:

if (
  typeof obj.port !== 'number' ||
  !Number.isInteger(obj.port) ||
  obj.port < 1 ||
  obj.port > 65535
) {
  throw new ConfigValidationError('auth.smtp.port', 'must be an integer between 1 and 65535');
}

A user who writes port = 0 will get a cryptic backend 400 rather than a parse-time error. Low blast radius but inconsistent with how min_interval_seconds is treated in the same function.


[Functionality] Discovery comment in TOML renderer hardcodes SMTP_PASSWORD regardless of the actual ref — src/lib/config-toml.ts:60

lines.push('# password is managed via secrets — run `insforge secrets add SMTP_PASSWORD <value>`');
lines.push(`password = ${JSON.stringify(smtp.password)}`);

stringifyConfigToml is a general renderer, but the comment always mentions SMTP_PASSWORD. If smtp.password is "env(PROD_SMTP_PASS)", the output becomes:

# password is managed via secrets — run `insforge secrets add SMTP_PASSWORD <value>`
password = "env(PROD_SMTP_PASS)"

The comment is misleading — it tells the user to create a secret under the wrong name. Extract the name from the env() ref:

const ref = parseEnvRef(smtp.password);
const secretName = ref ?? 'SMTP_PASSWORD';
lines.push(`# password is managed via secrets — run \`insforge secrets add ${secretName} "<value>"\``);

(Export-generated TOML always uses env(SMTP_PASSWORD) today so the comment is correct there — but this will bite the first user who names their secret differently.)


[Functionality] Secret-not-found detection relies on error message string — src/lib/config-secrets.ts:108–116

const message = (err as Error).message ?? '';
if (/not found/i.test(message)) {

This creates implicit coupling to ossFetch's error message format. If the backend error wording changes (e.g. "Secret 'X' does not exist"), the regex fails to match and the error silently degrades from the actionable SECRET_NOT_FOUND (with insforge secrets add hint) to the generic SECRET_LOOKUP_FAILED. The mock in the test (new Error('Secret not found: MISSING')) hard-codes the matching string, so a backend message change wouldn't be caught.

Not urgent since it only affects error quality, not correctness — but it's worth tracking. Ideally ossFetch would surface the HTTP status so callers can branch on 404 vs 5xx without string-matching.


[Software Engineering] metadataSupports has no exhaustiveness check on the DiffChange union — src/lib/config-capabilities.ts:56

Unlike applyChange() (which has the const _exhaustive: never = change guard at line 205–206 of apply.ts), metadataSupports silently returns false for any unrecognized change.section. If a new DiffChange variant is added in a future PR, metadataSupports will silently skip it (every apply of that new section will be put into skipped[]). Adding the same exhaustiveness pattern here would catch that at compile time.


Information

Duplicated RawAuthMetadata / RawMetadataResponse interfaces — apply.ts:17–29 and export.ts:14–26

Identical interface definitions in two files. Not worth splitting into a shared module for two consumers, but flag for the next person who adds a third field — that's the right moment to consolidate.


Verdict

Approved (informational — explicit GitHub approval is a separate human action)

No Critical findings. Four non-blocking items. The secret-handling model is well-designed, test coverage is strong, and scope discipline is clean. The port-range validation and comment-rendering issues are the most concrete and worth a quick follow-up before the npm release (0.1.76 tag).

tonychang04 and others added 2 commits May 12, 2026 18:45
Mirrors the SMTP/auth pattern: per-section capability probe gates the
write so old/self-host backends never receive a PUT they can't honor.

Wire:
- [deployments] subdomain = "my-app"  → PUT /api/deployments/slug { slug: "my-app" }
- [deployments] subdomain = ""        → PUT { slug: null } (clear)
- absent section                      → default-keep (live state preserved)

Version skew handled in metadataSupports() via presence-only probe of the
new metadata slice exposed in InsForge#1259:
- pre-#1259 backend (any topology) → no `deployments` key → skip with named warning
- post-#1259 self-host             → getConfigMetadata returns undefined → same skip
- post-#1259 cloud (slug null/set) → slice present → proceed

Empty-string in TOML normalizes to null in the diff/apply layer (TOML
lacks a null literal). Export emits [deployments] only when a slug is
actually set, to avoid implying "clear on apply" on a fresh export.

CLI bumped 0.1.76 → 0.1.77.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-as-code PR

Combining both follow-ups (SMTP and deployment slug) into one PR. Both
hang off the same config-as-code scaffolding (capability probe, diff,
apply, export) so a single review is cheaper than two.

Conflict resolution: each section is additive. Kept both DiffChange
variants in the discriminated union, both apply handlers, both
capability probes, both render paths, and both describe blocks in the
tests. The existing "both sections present" / "older backend" tests
now assert that the OTHER section gets skipped, since the fixtures
only carry one slice.

312 tests pass; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tonychang04 tonychang04 changed the title feat(cli): add [auth.smtp] section to config-as-code with env() password resolution feat(cli): add [auth.smtp] and [deployments] sections to config-as-code May 13, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/commands/config/apply.test.ts (1)

93-97: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reset shared metadata state in beforeEach.

nextMetadataResponse is still not reset per test. On Line 93, please reset it (e.g., {}) to prevent test-order coupling.

Suggested patch
 beforeEach(() => {
   vi.clearAllMocks();
   secretsStore.clear();
+  nextMetadataResponse = {};
   tmp = mkdtempSync(join(tmpdir(), 'insforge-apply-test-'));
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/config/apply.test.ts` around lines 93 - 97, Tests share mutable
state via the nextMetadataResponse variable causing test-order coupling; update
the beforeEach block to reset nextMetadataResponse to an empty object (e.g.,
nextMetadataResponse = {}) so each test starts with a clean metadata state—look
for the beforeEach that calls vi.clearAllMocks(), secretsStore.clear(), and
mkdtempSync and add the reset of nextMetadataResponse there.
🧹 Nitpick comments (1)
src/commands/config/apply.test.ts (1)

13-39: ⚡ Quick win

Make ossFetchMock fail-closed for unknown routes.

The permissive fallback response can hide unexpected API calls. Prefer throwing on unhandled method + path and explicitly allow known endpoints only.

Suggested hardening
 const ossFetchMock = vi.fn(async (path: string, init?: RequestInit) => {
+  const method = init?.method ?? 'GET';
   if (path === '/api/metadata' && (!init || init.method === undefined || init.method === 'GET')) {
     return new Response(JSON.stringify(nextMetadataResponse), {
       status: 200,
       headers: { 'content-type': 'application/json' },
     });
   }
@@
-  return new Response(JSON.stringify({ ok: true }), {
-    status: 200,
-    headers: { 'content-type': 'application/json' },
-  });
+  if (
+    method === 'PUT' &&
+    (path === '/api/auth/config' ||
+      path === '/api/auth/smtp-config' ||
+      path === '/api/deployments/slug')
+  ) {
+    return new Response(JSON.stringify({ ok: true }), {
+      status: 200,
+      headers: { 'content-type': 'application/json' },
+    });
+  }
+
+  throw new Error(`Unhandled ossFetch call in test: ${method} ${path}`);
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/config/apply.test.ts` around lines 13 - 39, The ossFetchMock
currently returns a permissive fallback Response for any unhandled path/method
which can mask unexpected API calls; update ossFetchMock to fail-closed by
throwing an Error for unknown method+path combinations and only handle the
explicit routes used in tests (the '/api/metadata' GET case and the
'/api/secrets/:key' GET case that uses secretMatch and secretsStore); ensure any
other methods or paths (including mismatched methods for known endpoints) throw
a descriptive error mentioning the method and path so tests surface unintended
requests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/config-toml.test.ts`:
- Around line 136-152: The test for stringifyConfigToml currently checks out
not.toContain('password') which is too broad; update the assertion in the "omits
password line entirely when password is undefined" test so it specifically
asserts the password key line is absent (e.g., ensure the output does not
contain a password key assignment or any line that begins with the password key)
rather than any occurrence of the word "password"; locate the test around the
it(...) block referencing stringifyConfigToml and replace the generic
containment check with a targeted check (e.g., negative regex match or not
containing the exact "password =" pattern) to avoid false positives from
comments.

---

Duplicate comments:
In `@src/commands/config/apply.test.ts`:
- Around line 93-97: Tests share mutable state via the nextMetadataResponse
variable causing test-order coupling; update the beforeEach block to reset
nextMetadataResponse to an empty object (e.g., nextMetadataResponse = {}) so
each test starts with a clean metadata state—look for the beforeEach that calls
vi.clearAllMocks(), secretsStore.clear(), and mkdtempSync and add the reset of
nextMetadataResponse there.

---

Nitpick comments:
In `@src/commands/config/apply.test.ts`:
- Around line 13-39: The ossFetchMock currently returns a permissive fallback
Response for any unhandled path/method which can mask unexpected API calls;
update ossFetchMock to fail-closed by throwing an Error for unknown method+path
combinations and only handle the explicit routes used in tests (the
'/api/metadata' GET case and the '/api/secrets/:key' GET case that uses
secretMatch and secretsStore); ensure any other methods or paths (including
mismatched methods for known endpoints) throw a descriptive error mentioning the
method and path so tests surface unintended requests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 49cc9119-6a09-43d1-b529-9e795fc57439

📥 Commits

Reviewing files that changed from the base of the PR and between 88842d8 and 35af5b9.

📒 Files selected for processing (12)
  • package.json
  • src/commands/config/apply.test.ts
  • src/commands/config/apply.ts
  • src/commands/config/export.test.ts
  • src/commands/config/export.ts
  • src/lib/config-capabilities.test.ts
  • src/lib/config-capabilities.ts
  • src/lib/config-diff.ts
  • src/lib/config-format.ts
  • src/lib/config-schema.ts
  • src/lib/config-toml.test.ts
  • src/lib/config-toml.ts
✅ Files skipped from review due to trivial changes (1)
  • package.json
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/lib/config-toml.ts
  • src/commands/config/export.ts
  • src/lib/config-capabilities.ts
  • src/commands/config/apply.ts
  • src/lib/config-diff.ts
  • src/lib/config-schema.ts
  • src/commands/config/export.test.ts

Comment on lines +136 to +152
it('omits password line entirely when password is undefined', () => {
const out = stringifyConfigToml({
auth: {
smtp: {
enabled: false,
host: '',
port: 587,
username: '',
sender_email: '',
sender_name: '',
min_interval_seconds: 60,
},
},
});
expect(out).toContain('[auth.smtp]');
expect(out).not.toContain('password');
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tighten password omission assertion to target the key, not any text

At Line 151, not.toContain('password') is broader than the requirement (“omit password field”) and can cause false failures if comments include that word. Assert against the key line instead.

Suggested change
-    expect(out).not.toContain('password');
+    expect(out).not.toMatch(/^\s*password\s*=/m);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('omits password line entirely when password is undefined', () => {
const out = stringifyConfigToml({
auth: {
smtp: {
enabled: false,
host: '',
port: 587,
username: '',
sender_email: '',
sender_name: '',
min_interval_seconds: 60,
},
},
});
expect(out).toContain('[auth.smtp]');
expect(out).not.toContain('password');
});
it('omits password line entirely when password is undefined', () => {
const out = stringifyConfigToml({
auth: {
smtp: {
enabled: false,
host: '',
port: 587,
username: '',
sender_email: '',
sender_name: '',
min_interval_seconds: 60,
},
},
});
expect(out).toContain('[auth.smtp]');
expect(out).not.toMatch(/^\s*password\s*=/m);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/config-toml.test.ts` around lines 136 - 152, The test for
stringifyConfigToml currently checks out not.toContain('password') which is too
broad; update the assertion in the "omits password line entirely when password
is undefined" test so it specifically asserts the password key line is absent
(e.g., ensure the output does not contain a password key assignment or any line
that begins with the password key) rather than any occurrence of the word
"password"; locate the test around the it(...) block referencing
stringifyConfigToml and replace the generic containment check with a targeted
check (e.g., negative regex match or not containing the exact "password ="
pattern) to avoid false positives from comments.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 13, 2026

Greptile Summary

Extends the config-as-code pipeline with two new TOML sections: [auth.smtp] (with env-ref-only password handling, force-resend semantics, and pre-flight secret resolution) and [deployments] (custom subdomain, with empty-string-as-clear-signal convention). Both sections are gated by backend capability probing and fall into skipped[] on older backends.

  • Adds SmtpConfig and DeploymentsConfig schema types with validators; literal passwords are rejected at parse time, only env(NAME) refs accepted.
  • Introduces resolveEnvRef for pre-flight secret lookup against the InsForge secrets store before any PUT, with distinct error codes for missing/empty secrets.
  • Force-resend semantics ensure that every apply with a password env-ref re-sends the current secret value (since the backend ciphertext can never be diffed client-side).

Confidence Score: 5/5

Safe to merge — the new SMTP and deployments sections are well-isolated behind capability gates, all apply paths are gated by pre-flight checks, and no existing behavior is regressed.

The core apply/export/diff logic is correct and thoroughly unit-tested (51 tests). Force-resend semantics are intentional and documented. Secrets never appear in plan output or TOML. Both findings are edge-case consistency issues that do not affect the main happy path or introduce data loss.

src/lib/config-capabilities.ts — the smtpConfig presence check diverges from the truthy checks in apply.ts and export.ts; worth aligning before the backend is deployed broadly.

Important Files Changed

Filename Overview
src/lib/config-capabilities.ts Probes for smtpConfig via key-presence ('smtpConfig' in raw.auth) while export.ts and liveFromMetadata use a truthy check; diverges on the edge case of smtpConfig: null from the backend.
src/commands/config/apply.ts New liveFromMetadata helper and applyChange variants are clean; RawAuthMetadata/RawMetadataResponse interfaces are duplicated from export.ts (flagged in a previous review thread).
src/commands/config/export.ts SMTP export correctly emits placeholder and discovery comment; uses a truthy smtpConfig check that diverges from metadataSupports key-presence check.
src/lib/config-secrets.ts Adds resolveEnvRef() for pre-flight secret lookup; logic is sound but has a noted comment/code inconsistency (ossFetch throw vs res.ok guard) already flagged in a previous review thread.
src/lib/config-diff.ts Adds auth.smtp and deployments DiffChange variants with clear discriminated union; force-resend logic is well-motivated and correct; exhaustiveness guards are in place.
src/lib/config-format.ts auth.smtp formatter adds a block with per-field change lines; the join with extra spaces combined with per-line indent produces double indentation relative to other change entries (flagged in previous review thread).
src/lib/config-schema.ts SmtpConfig and DeploymentsConfig types and validators are well-structured; port range, integer, non-negative, and env-ref-only checks are all present and tested.
src/lib/config-toml.ts stringifyConfigToml correctly uses parseEnvRef to name the actual secret in the discovery comment; round-trips correctly through parse/stringify for SMTP and deployments.

Sequence Diagram

sequenceDiagram
    participant CLI
    participant Metadata as GET /api/metadata
    participant Secrets as GET /api/secrets/:name
    participant SmtpAPI as PUT /api/auth/smtp-config
    participant SlugAPI as PUT /api/deployments/slug

    CLI->>Metadata: fetch raw metadata
    Metadata-->>CLI: "{ auth: { smtpConfig, allowedRedirectUrls }, deployments }"

    CLI->>CLI: liveFromMetadata() → LiveConfig
    CLI->>CLI: diffConfig(live, file) → DiffResult

    loop each DiffChange
        CLI->>CLI: metadataSupports(raw, change)?
        alt not supported
            CLI->>CLI: "skipped.push({ key, reason })"
        else auth.smtp change
            alt passwordEnvRef present
                CLI->>Secrets: GET /api/secrets/:name
                Secrets-->>CLI: "{ value } or throw"
            end
            CLI->>SmtpAPI: "PUT { enabled, host, port, username, senderEmail, senderName, minIntervalSeconds, [password] }"
        else deployments.subdomain change
            CLI->>SlugAPI: "PUT { slug: string | null }"
        end
    end

    CLI->>CLI: "emit { applied[], skipped[] }"
Loading

Reviews (2): Last reviewed commit: "fix(config): address AI reviewer's non-b..." | Re-trigger Greptile

Comment thread src/lib/config-toml.ts
Comment thread src/lib/config-format.ts
if (c.passwordEnvRef) {
lines.push(` (password force-resent from env(${c.passwordEnvRef}))`);
}
return lines.join('\n ');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The SMTP change formatter joins inner lines with ' ' (4 extra spaces) while each inner line already carries its own 4-space indent prefix. The result is 8 spaces before each field line. Combined with formatPlan's own 4-space wrap via ` ${formatChange(c)}`, field lines end up at 12-space indent while the ~ smtp config: header sits at 4-space. Use a plain ' ' join and keep the prefix in the pushed lines.

Suggested change
return lines.join('\n ');
return lines.join('\n');

Comment thread src/lib/config-secrets.ts
Comment on lines +104 to +132
// ossFetch throws on any non-2xx, swallowing the status. Recover the
// "missing secret" case from the error message — the backend's NOT_FOUND
// path is the most common failure here and deserves the named code +
// actionable hint, not a generic network error.
const message = (err as Error).message ?? '';
if (/not found/i.test(message)) {
throw new CLIError(
`${fieldPath} references env(${secretName}) but no such secret exists.\n` +
` fix: insforge secrets add ${secretName} "<value>"`,
1,
'SECRET_NOT_FOUND',
);
}
// Other failures: re-wrap with the path context so users see what we
// were trying to resolve when the lookup blew up.
throw new CLIError(
`failed to resolve env(${secretName}) for ${fieldPath}: ${message}`,
1,
'SECRET_LOOKUP_FAILED',
);
}

if (!res.ok) {
throw new CLIError(
`failed to resolve env(${secretName}) for ${fieldPath}: HTTP ${res.status}`,
1,
'SECRET_LOOKUP_FAILED',
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inconsistency between comment and res.ok guard

The comment at line 104 says "ossFetch throws on any non-2xx, swallowing the status" — implying the if (!res.ok) guard at line 126 is dead code. But the unit test mocks ossFetch to resolve (not throw) with status: 500, which is the only way res.ok would ever trigger. One of these is wrong: either the comment is incorrect (ossFetch can return non-ok responses without throwing), or the test is exercising an unreachable path that never runs in production.

Three real findings from the review verdict:

1. Port range validation — auth.smtp.port now rejects values outside
   1-65535 with a clear parse-time error. The validator was already
   opinionated for min_interval_seconds; being inconsistent here was the
   bad pattern (the actual concern from the reviewer, not duplicated
   defense-in-depth).

2. Discovery comment named the actual env ref — when the user writes
   password = "env(PROD_SMTP_PASS)", the rendered hint now says
   "insforge secrets add PROD_SMTP_PASS …" instead of unconditionally
   telling them to create SMTP_PASSWORD. Pointing at the wrong secret
   name would have bitten anyone with a non-default ref.

3. Exhaustiveness check on metadataSupports — mirrors the pattern in
   applyChange so a future DiffChange variant will TS-error at compile
   time instead of silently routing every apply of that section into
   skipped[] forever.

Skipped per the reviewer's own ranking: the "secret-not-found relies on
error string" suggestion (reviewer explicitly: "only affects error
quality, not correctness…not urgent") and the duplicated
RawMetadataResponse interfaces ("flag for the next person who adds a
third field").

314 tests pass (+2 new); lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@jwfing jwfing left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — feat(cli): add [auth.smtp] and [deployments] sections to config-as-code

Summary: Well-structured step-2 implementation of the SMTP config-as-code initiative. The capability-gate protocol, force-resend semantics, and secret-opaque plan output are all correct; test coverage is solid across the new surface area.


Requirements context

No /docs/superpowers/ directory exists in this repo — assessed against the PR description alone. The PR is self-contained with clear design rationale; all claimed behaviors were verified against the implementation.


Findings

Critical

(none)


Suggestion

[Software Engineering] Duplicate RawAuthMetadata / RawMetadataResponse types — apply.ts:17–39 and export.ts:14–36

Both files define identical RawAuthMetadata and RawMetadataResponse interfaces. They already diverge slightly in comments and will diverge further as the metadata shape grows (e.g. future [auth.providers] section). Extracting them once to src/lib/config-metadata.ts (or similar) prevents silent drift and makes both callers authoritative off the same type.


[Functionality] Fragile "not-found" detection in resolveEnvRefconfig-secrets.ts:103–116

const message = (err as Error).message ?? '';
if (/not found/i.test(message)) {

ossFetch throws for any non-2xx, swallowing the HTTP status. The regex /not found/i recovers the 404 case from the message string, but it will also match unrelated failures — for example, a reverse-proxy or network error whose message contains "route not found", "host not found", or a secret name that happens to contain "not found". The user then receives a misleading insforge secrets add hint when the actual problem is connectivity.

More robust: have ossFetch (or a wrapper) attach the numeric status to the thrown error so callers can branch on err.status === 404 rather than string-matching. If that's out of scope here, the fallback fix is a tighter pattern — e.g. /^secret not found:/i to match only the backend's exact error format, and leave all other throw paths in the SECRET_LOOKUP_FAILED branch.

The test at config-secrets.test.ts:100 deliberately mirrors the current throw format, which means this assumption is tested — but the test would also pass if the regex were accidentally over-broad.


[Functionality] Omitting SMTP fields from TOML silently applies renderer defaults — config-diff.ts:230–241 + apply.ts:186–194

renderFileSmtp fills absent fields with hardcoded defaults (port: 587, min_interval_seconds: 60, sender_email: '', …). Because applyChange builds the upsert body from change.to (the full rendered view), a minimal TOML like:

[auth.smtp]
enabled = true
host = "smtp.example.com"
username = "user"
password = "env(SMTP_PASSWORD)"

will PUT port=587, sender_email='', etc., overwriting any non-default values the backend currently holds for those fields. This is whole-object upsert semantics and the design rationale is noted in the PR, but the plan output ("~ smtp config: …") only shows fields that change — so a user with port=25 on the backend will see port: 25 → 587 in their plan, but may not understand why. A one-line note in the formatted output (e.g., "omitted fields will be reset to defaults") would reduce surprise.


Information

Version bump mismatch in PR description: The body says "0.1.75 → 0.1.76" but package.json shows "0.1.76" → "0.1.77". The code is correct; the description text is stale.

Test coupling to ossFetch throw format — config-secrets.test.ts:100: The mock reproduces the exact string "Secret not found: MISSING" to drive the SECRET_NOT_FOUND branch. If ossFetch is ever refactored to produce a different message format, this test continues to pass (it controls both sides of the mock) while production silently falls through to SECRET_LOOKUP_FAILED. Worth a comment noting the coupling.

stringifyConfigToml — no test for fully-empty config (config-toml.ts:23–61): The TOML serializer's final replace(/\n+$/, '\n') is exercised by every test with content, but there's no test for stringifyConfigToml({}) (no sections at all). Minor gap.


Verdict

approved (informational — no Critical findings; explicit GitHub approval is a separate human action)

The three Suggestion items are clean-up / robustness improvements rather than blockers. The core capability-gate protocol, secret-opaque diff output, and force-resend semantics are all implemented correctly. No security issues found.

Copy link
Copy Markdown
Member

@jwfing jwfing left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, approved.

@tonychang04 tonychang04 merged commit 007bbb1 into main May 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants