Skip to content

feat(tools): stack-env ergonomics — summary/hint, remove_stack_env_vars, collision check#105

Merged
strausmann merged 10 commits into
mainfrom
feat/stack-env-ergonomics
Jul 17, 2026
Merged

feat(tools): stack-env ergonomics — summary/hint, remove_stack_env_vars, collision check#105
strausmann merged 10 commits into
mainfrom
feat/stack-env-ergonomics

Conversation

@strausmann

Copy link
Copy Markdown
Owner

Was

Drei additive, nicht-breaking Verbesserungen rund um Stack-Env, hervorgegangen aus einer Investigation zum Dockhand-Zwei-Speicher-Modell (DB-Secrets vs. .env):

  • A) update_stack_env liefert im merge-Modus zusätzlich summary {added, updated, preserved, removed} + einen hint, wenn nichts entfernt wurde (das „ich-wollte-eigentlich-löschen"-Muster). replace bleibt unverändert. success/count bleiben.
  • B) remove_stack_env_vars(env, name, keys[]) — entfernt Keys stack-typ-bewusst über beide Speicher: DB-Rebuild der vollen verbleibenden Menge minus Ziel-Keys (Secrets via ***-Bewahrung; DB-gehaltene Nicht-Secrets bleiben erhalten), .env-Rewrite für .env-gehaltene Keys. Kollisionen aus beiden. Return { removed, not_found }.
  • D) check_stack_env_collisions(env, name) — read-only, meldet Keys die gleichzeitig DB-Secret und in .env sind.
  • Reine Helfer diffEnvVars, parseDotEnvKeys, removeKeysFromDotEnv (inkl. export KEY=-Support).

Qualität

  • TDD strict, 319 Tests grün, typecheck + build + docker build sauber.
  • Whole-Branch-Review fand + verifizierte einen Critical-Datenverlust (frühe B-Fassung hätte DB-gehaltene Nicht-Secrets von git-Stacks gelöscht) → behoben, Regressionstest ergänzt.
  • Merge-Fail-Safe von update_stack_env (GET-Fehler → kein PUT) unverändert.

Motivation/Details: Investigation zum Zwei-Speicher-Modell (AFFiNE-Stack-Env-Duplikat auf HHDOCKER03).

…-secret loss)

The /env PUT payload previously kept only isSecret vars from the
structured GET, dropping any non-secret DB-managed variables (git
stacks store non-secrets in the DB too, not just in .env). Rebuild
the full remaining DB-backed set (secrets masked '***', DB-managed
non-secrets kept as-is) minus the removal targets instead.

Response shape changes from {removed_secrets, removed_env, not_found}
to {removed, not_found} (plus optional error on partial .env-rewrite
failure) since a removal target can now live in either store.
parseDotEnvKeys and removeKeysFromDotEnv treated a leading "export "
token as part of the key, so "export KEY=value" lines were neither
recognized as an existing key nor removable. Strip the optional
"export " prefix after trimming, before extracting the key.
…or path

- remove_stack_env_vars: regression test asserting that removing one
  secret does not drop unrelated DB-managed non-secrets (git-stack
  vars not present in .env) from the rebuilt /env PUT payload.
- remove_stack_env_vars: error path for the first (/env) PUT failing —
  reported via `error`, and the /env/raw PUT must not be attempted.
- env-helpers: parseDotEnvKeys/removeKeysFromDotEnv "export KEY=" cases.
Copilot AI review requested due to automatic review settings July 17, 2026 12:52

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces two new MCP tools, remove_stack_env_vars and check_stack_env_collisions, along with helper utilities for parsing, diffing, and removing environment variables across database-backed and .env stores. It also updates update_stack_env to return a diff summary and hint in merge mode, and adds comprehensive test coverage. The review feedback focuses on improving robustness by adding defensive checks when processing variables returned from external API responses in diffEnvVars, remove_stack_env_vars, and check_stack_env_collisions to prevent potential runtime TypeError crashes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/utils/env-helpers.ts Outdated
Comment on lines +19 to +35
const existingByKey = new Map(existing.map((v) => [v.key, v]));
const payloadKeys = new Set(payload.map((v) => v.key));

const added: string[] = [];
const updated: string[] = [];
for (const v of payload) {
const prev = existingByKey.get(v.key);
if (!prev) {
added.push(v.key);
continue;
}
const valueChanged = v.value !== '***' && v.value !== prev.value;
const secretChanged = v.isSecret !== undefined && v.isSecret !== prev.isSecret;
if (valueChanged || secretChanged) updated.push(v.key);
}

const untouched = existing.filter((v) => !payloadKeys.has(v.key)).map((v) => v.key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

In diffEnvVars wird das existing-Array direkt über v.key gemappt, ohne zu prüfen, ob v definiert ist oder ob v.key ein String ist. Da existing direkt aus der externen API-Antwort stammt (existingVars in update_stack_env), kann es null-, undefined- oder fehlerhafte Elemente enthalten. Dies führt zu einem Laufzeit-TypeError und bringt das Tool zum Absturz.

Wir sollten das existing-Array vor der Verarbeitung defensiv filtern, um sicherzustellen, dass alle Elemente valide Objekte mit einer key-Eigenschaft sind.

  const validExisting = existing.filter((v) => v && typeof v.key === 'string');
  const existingByKey = new Map(validExisting.map((v) => [v.key, v]));
  const payloadKeys = new Set(payload.map((v) => v.key));

  const added: string[] = [];
  const updated: string[] = [];
  for (const v of payload) {
    const prev = existingByKey.get(v.key);
    if (!prev) {
      added.push(v.key);
      continue;
    }
    const valueChanged = v.value !== '***' && v.value !== prev.value;
    const secretChanged = v.isSecret !== undefined && v.isSecret !== prev.isSecret;
    if (valueChanged || secretChanged) updated.push(v.key);
  }

  const untouched = validExisting.filter((v) => !payloadKeys.has(v.key)).map((v) => v.key);

Comment thread src/tools/stacks.ts Outdated
Comment on lines +247 to +249
const vars = Array.isArray(structured?.variables) ? structured.variables : [];
const structuredKeys = new Set(vars.map((v) => v.key));
const secretKeys = new Set(vars.filter((v) => v.isSecret).map((v) => v.key));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In remove_stack_env_vars wird das vars-Array direkt über v.key und v.isSecret gemappt und gefiltert, ohne zu prüfen, ob v definiert ist oder ob v.key ein String ist. Wenn die API-Antwort null-, undefined- oder fehlerhafte Elemente enthält, führt dies zu einem Laufzeit-TypeError und bringt das Tool zum Absturz.

Wir sollten das vars-Array defensiv filtern, um sicherzustellen, dass alle Elemente valide Objekte mit einer key-Eigenschaft sind, bevor wir sie verarbeiten. Dies entspricht dem defensiven Muster, das auch in update_stack_env verwendet wird.

Suggested change
const vars = Array.isArray(structured?.variables) ? structured.variables : [];
const structuredKeys = new Set(vars.map((v) => v.key));
const secretKeys = new Set(vars.filter((v) => v.isSecret).map((v) => v.key));
const vars = (Array.isArray(structured?.variables) ? structured.variables : [])
.filter((v) => v && typeof v.key === 'string');
const structuredKeys = new Set(vars.map((v) => v.key));
const secretKeys = new Set(vars.filter((v) => v.isSecret).map((v) => v.key));

Comment thread src/tools/stacks.ts Outdated
Comment on lines +306 to +308
const secretKeys = new Set(
(Array.isArray(structured?.variables) ? structured.variables : [])
.filter((v) => v.isSecret).map((v) => v.key));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In check_stack_env_collisions wird das structured?.variables-Array direkt über v.isSecret und v.key gefiltert und gemappt, ohne zu prüfen, ob v definiert ist oder ob v.key ein String ist. Wenn die API-Antwort null-, undefined- oder fehlerhafte Elemente enthält, führt dies zu einem Laufzeit-TypeError und bringt das Tool zum Absturz.

Wir sollten das Array defensiv filtern, um sicherzustellen, dass alle Elemente valide Objekte mit einer key-Eigenschaft sind, bevor wir isSecret prüfen.

      const secretKeys = new Set(
        (Array.isArray(structured?.variables) ? structured.variables : [])
          .filter((v) => v && v.isSecret && typeof v.key === 'string')
          .map((v) => v.key));

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Additive improvements to the stack environment-variable tooling to better support Dockhand’s two-store model (DB secrets vs .env), including safer deletion ergonomics and better merge-mode feedback.

Changes:

  • Extend update_stack_env (merge mode only) to return a summary of changes plus a user-facing hint when nothing was removed.
  • Add remove_stack_env_vars to remove keys across DB-backed variables and .env (with masking/preservation rules).
  • Add check_stack_env_collisions to detect keys that exist both as DB secrets and in .env, plus helper utilities/tests for .env parsing and key removal.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/tools/stacks.ts Adds merge-mode summary/hint to update_stack_env; introduces remove_stack_env_vars and check_stack_env_collisions.
src/utils/env-helpers.ts New helper utilities for env diffing and .env parsing/rewriting.
tests/stack-env-merge-behavior.test.ts Tests merge-mode summary + hint, and asserts replace mode contract remains unchanged.
tests/stack-env-remove.test.ts Tests deletion behavior across stores, including a regression for DB-held non-secrets.
tests/stack-env-collisions.test.ts Tests collision reporting for DB secret vs .env duplicates.
tests/env-helpers.test.ts Unit tests for diffEnvVars, parseDotEnvKeys, and removeKeysFromDotEnv.
tests/tool-descriptions.test.ts Ensures the new tools are included in the expected tooling cluster.

Comment thread src/tools/stacks.ts
Comment on lines +192 to +208
const result = (await client.put(
`/api/stacks/${encodePath(name)}/env`, { variables: finalVariables },
{ env: environmentId })) as Record<string, unknown>;

const hint =
diff && diff.added.length === 0 && diff.preserved.length > 0
? `merge mode preserved ${diff.preserved.length} variable(s) you did not include and removed nothing. To remove variables use remove_stack_env_vars, or update_stack_env with mode="replace".`
: undefined;

return jsonResponse({
...result,
...(diff ? { summary: {
added: diff.added.length, updated: diff.updated.length,
preserved: diff.preserved.length, removed: diff.removed.length,
} } : {}),
...(hint ? { hint } : {}),
});
Comment thread src/tools/stacks.ts
Comment on lines +284 to +290
} catch (e) {
return jsonResponse({
removed: removed.filter((k) => !envTargets.includes(k)),
not_found: notFound,
error: `DB entries removed, but .env rewrite failed: ${e instanceof Error ? e.message : String(e)}`,
});
}
Gemini review: diffEnvVars, remove_stack_env_vars and check_stack_env_collisions
mapped over externally-sourced variable arrays without guarding against
null/undefined elements or non-string keys — a malformed API response could
crash the tool with a TypeError. Filter for valid { key: string } entries first,
consistent with the existing guard in update_stack_env. Adds a defensive test.
@strausmann

Copy link
Copy Markdown
Owner Author

Danke @gemini-code-assist — alle drei Findings adressiert (Commit defensively filter malformed API variable entries): diffEnvVars, remove_stack_env_vars und check_stack_env_collisions filtern die extern gelieferten Variablen-Arrays jetzt auf valide { key: string }-Einträge (konsistent mit dem bestehenden Guard in update_stack_env), plus ein defensiver Test. 320 Tests grün, typecheck sauber.

@gemini-code-assist

Copy link
Copy Markdown

Danke für das Update. Da die defensiven Filter implementiert wurden und die Testabdeckung sowie der Typecheck erfolgreich sind, habe ich keine weiteren Anmerkungen. Die Änderungen sehen aus technischer Sicht konsistent aus.

@strausmann
strausmann merged commit b18d5ca into main Jul 17, 2026
4 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 1.9.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

strausmann added a commit that referenced this pull request Jul 18, 2026
…) (#110)

* feat(tools): add upsertDotEnv helper

Pure helper for env-var auto-routing: replaces an existing KEY= line
(preserving an optional "export " prefix, comments, blank lines and
order) or appends a new KEY=value line when the key is absent.
Prepares update_stack_env to write isSecret:false vars into .env.

* feat(tools): route isSecret:false vars to .env in update_stack_env

update_stack_env used to send every variable (secret or not) through
PUT /env, the DB-only store. Non-secrets landed in an orphaned DB row
that get_stack_env never surfaces, since it always reads non-secrets
from the .env file — the value was silently never applied (#109).

update_stack_env now auto-routes: isSecret:true -> DB (PUT /env, as
before), isSecret:false -> the .env file (PUT /env/raw), upserted via
upsertDotEnv in merge mode or fully rebuilt in replace mode. The
merge-mode structured GET stays load-bearing (GET failure -> no write
at all, per #105). Response gains db.secretsWritten/env.nonSecretsWritten
so callers can see where a value actually landed; a failure in the .env
step after a successful DB write is reported as a partial success
(error set, db.secretsWritten still reflects what was persisted).

Replace mode now also does a best-effort GET purely to compute the
summary (a GET failure never blocks the write, since replace is a
deliberate full wipe) — updates the #105 "replace never GETs" contract
accordingly.

* test(tools): repro tests for update_stack_env review findings

Locks in the intended behaviour for the review findings on
update_stack_env's DB/.env auto-routing (see #109 follow-up):

- Critical 1: degrading the only existing secret to isSecret:false must
  still flush the stale encrypted DB row (secrets.length alone is not
  enough to decide whether the DB PUT should fire).
- Critical 2: promoting a plain .env key to isSecret:true must scrub it
  out of .env, not just add it to the DB.
- Critical 3: an orphaned non-secret DB row untouched by a call must be
  migrated into .env before the DB PUT (DELETE-all+INSERT) drops it, and
  must never be reported as "preserved" in the summary.
- Critical 4: replace mode reverts to the original #105 contract - no
  GET, no summary/hint (removes the #109 best-effort summary GET).
- Important 5: the merge summary baseline must combine DB secrets AND
  .env keys, so a key already tracked in .env is "updated" not "added",
  and an omitted .env key is "preserved".
- Important 6: extra fields on the underlying PUT responses should be
  passed through additively.
- Minor 7: duplicate keys in the payload must be de-duplicated before
  building the DB/.env write sets.

Also updates the "pure secret payload" test: promoting/creating a
secret now always checks .env for a same-named plaintext copy to
scrub (Critical 2), so a raw GET+PUT happens even for a brand-new key.

These tests are red against the current handler.

* fix(tools): correct update_stack_env DB/.env routing edge cases

Fixes 7 review findings on the merge/replace auto-routing added in the
previous commits:

- Critical 1: the DB PUT now also fires when secrets existed before
  the call but the payload demotes the last one to isSecret:false
  (secrets.length would otherwise be 0 and the stale encrypted row
  would never be purged).
- Critical 2: keys explicitly promoted to isSecret:true this call
  (promotedKeys) are now scrubbed from .env via removeKeysFromDotEnv,
  so the plaintext copy cannot linger next to the new DB secret.
- Critical 3: orphaned non-secret DB rows (isSecret:false/undefined
  entries in the structured store - not supposed to exist post-fix,
  but possible leftovers) are migrated into .env before the DB PUT
  would otherwise silently delete them via DELETE-all+INSERT.
- Critical 4: replace mode drops the #109 best-effort summary GET
  entirely - back to the original #105 contract (no GET, no summary,
  no hint) since replace is a deliberate full wipe, not a preview.
- Important 5: the merge summary's diff baseline now combines DB
  secrets (isSecret:true) with .env keys (parsed from the raw GET,
  when one already happened) instead of the raw structured GET, which
  used to include orphaned DB rows and misreport them as "preserved"
  right before they were deleted.
- Important 6: the JSON response now spreads through any extra fields
  on the underlying PUT responses before applying success/db/env/
  summary/hint/error, so passthrough API metadata is not dropped.
- Minor 7: the incoming variables array is de-duplicated by key (last
  one wins) before it feeds into any of the above.

All 340 tests pass, including the new repro suite from the previous
commit.

* fix(tools): protect live .env value from stale DB-orphan migration (N1)

Re-review found the Critical-3 migration could overwrite a live .env value
with the stale value of an orphaned DB non-secret row sharing the same key.
Only migrate orphaned DB rows whose key is NOT already in .env — the live
.env is authoritative for non-secrets; the stale DB row is dropped by the
DB PUT regardless.

* fix(tools): upsertDotEnv preserves trailing newline when appending

Gemini review: appending to .env content that ends with a trailing newline
inserted a spurious blank line (A=1\n\nB=2). Drop the empty trailing element
before appending and restore the newline afterwards. Adds a test.
github-actions Bot pushed a commit that referenced this pull request Jul 19, 2026
## [1.9.1](v1.9.0...v1.9.1) (2026-07-19)

### Bug Fixes

* **tools:** route isSecret:false vars to .env in update_stack_env ([#109](#109)) ([#110](#110)) ([b637efb](b637efb)), closes [#105](#105) [#105](#105) [#105](#105) [#105](#105)

### Documentation

* **release:** backfill v1.9.0 changelog entry ([#108](#108)) ([c4d49af](c4d49af))

### CI/CD

* **release:** restore @semantic-release/git and changelog plugins ([#107](#107)) ([e02ae91](e02ae91))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants