mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-21 21:09:49 +08:00
## Summary
Replaces `writeConfigObject` (destructive overwrite) with
`updateConfigObject` — an async, AI-aware updater that preserves
user-authored config structure (imports, external file references,
helpers).
**Dual-path approach:**
- **Fast path** (deterministic, no AI): plain static literal configs →
`override()` + in-memory validation + atomic write
- **Agent path** (custom structure): configs with `import x from
"./file.txt" with { type: "text" }` etc. → Claude agent edits the
external files in place, then validates
**Safety guarantees:**
- Snapshot/restore: config + all relative imports are captured before
the agent runs; rolled back on any failure
- In-memory validation on fast path (never write unvalidated bytes)
- Semantic check when config is evaluable; no-op detection + structural
check when it isn't
- Path traversal guard on imports (rejects `../` escapes)
- Agent isolation: `settingSources: []`, `strictMcpConfig: true`,
`CLAUDE_CODE_DISABLE_AUTO_MEMORY`, no Bash tool
- `scheduleSync` only fires after a successful update
- Bounded 120s timeout on agent runs (configurable via env var)
CI failures are preexisting on `dev`
(`ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` from overrides move without
lockfile regen); this branch has zero lockfile changes vs dev.
Link to Devin session:
https://app.devin.ai/sessions/cc7409a357bc472ea19fbed065f1229f
Requested by: @mantrakp04
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Introduced partial configuration update functionality with validation
and automatic rollback on failures.
* Enhanced configuration management with support for more complex file
structures and external references.
* **Chores**
* Added Claude Agent SDK dependency for configuration update operations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Documentation
Docs for this feature were added in this branch:
- **New page**
`docs-mintlify/guides/going-further/local-development.mdx` — covers
`stack dev`, the development-environment flow, and how dashboard edits
are written back to the local config file (structure-preserving fast
path vs. assistant path, external `import … with { type: "text" }`
templates, validation + rollback). Added to `docs.json` nav; also fixes
the previously-broken `/guides/going-further/local-development` links
from `index.mdx` and `self-host.mdx`.
- **`docs-mintlify/guides/going-further/cli.mdx`** — added a `stack dev`
("Run a development environment") section.
- **Skill-site AI prompts** — filled in the `config-docs` and
`dashboard-instructions` placeholders under
`packages/stack-shared/src/ai/unified-prompts/skill-site-prompt-parts/`,
and added a structure-preserving note to the setup prompt.
- **`CHANGELOG.md`** — user-facing entry.
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mantra <mantra@stack-auth.com>
157 lines
4.4 KiB
JavaScript
157 lines
4.4 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// Resilient wrapper for the demo dev command.
|
|
//
|
|
// The demo runs through the CLI because it needs development-environment
|
|
// credentials, but it must not call `pnpm -w run cli`: that route invokes Turbo
|
|
// builds, and several package builds start by removing dist/, racing with the
|
|
// package dev watchers that the root dev server is already running.
|
|
//
|
|
// Instead, run the CLI from TypeScript source and ask it to launch the dashboard
|
|
// through the dashboard package's RDE production command. The CLI still owns the
|
|
// development-environment env vars, so this stays close to the packaged path.
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { watch } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
|
|
const scriptDir = import.meta.dirname;
|
|
const demoRoot = resolve(scriptDir, "..");
|
|
const repoRoot = resolve(demoRoot, "../..");
|
|
|
|
const LOG_PREFIX = "[Hexclave dev-retry] ";
|
|
const RETRY_DEBOUNCE_MS = 2_000;
|
|
const RETRY_TIMEOUT_MS = 5_000;
|
|
const portPrefix = process.env.NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX ?? "81";
|
|
|
|
let cliChild;
|
|
let shutdownTimer;
|
|
|
|
function log(message) {
|
|
console.error(`${LOG_PREFIX}${message}`);
|
|
}
|
|
|
|
function spawnFromRepo(command, args, options = {}) {
|
|
return spawn(command, args, {
|
|
cwd: repoRoot,
|
|
stdio: "inherit",
|
|
env: process.env,
|
|
...options,
|
|
});
|
|
}
|
|
|
|
function runCliDev() {
|
|
return new Promise((resolvePromise, reject) => {
|
|
cliChild = spawnFromRepo("pnpm", [
|
|
"exec", "tsx", "packages/cli/src/index.ts",
|
|
"dev",
|
|
"--no-auto-update",
|
|
`--config-file=${join(demoRoot, "hexclave.config.ts")}`,
|
|
"--",
|
|
"pnpm", "--dir", "examples/demo", "run", "dev:inner",
|
|
], {
|
|
detached: process.platform !== "win32",
|
|
env: {
|
|
...process.env,
|
|
HEXCLAVE_CLI_DEV_DASHBOARD_COMMAND: "pnpm --dir apps/dashboard run dev:rde-production",
|
|
STACK_API_URL: `http://localhost:${portPrefix}02`,
|
|
STACK_DASHBOARD_URL: `http://localhost:${portPrefix}01`,
|
|
STACK_CLI_PUBLISHABLE_CLIENT_KEY: "this-publishable-client-key-is-for-local-development-only",
|
|
STACK_CLI_NO_AUTO_UPDATE: "1",
|
|
},
|
|
});
|
|
|
|
cliChild.on("close", (code, signal) => {
|
|
cliChild = undefined;
|
|
resolvePromise({ code: code ?? 1, signalled: signal != null });
|
|
});
|
|
cliChild.on("error", (err) => {
|
|
cliChild = undefined;
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
function waitForFileChanges() {
|
|
return new Promise((resolvePromise) => {
|
|
const watchDirs = [
|
|
join(repoRoot, "apps", "dashboard"),
|
|
join(repoRoot, "packages"),
|
|
];
|
|
const watchers = [];
|
|
let resolved = false;
|
|
|
|
const done = () => {
|
|
if (resolved) return;
|
|
resolved = true;
|
|
for (const w of watchers) {
|
|
try { w.close(); } catch { /* ignore */ }
|
|
}
|
|
resolvePromise();
|
|
};
|
|
|
|
for (const dir of watchDirs) {
|
|
try {
|
|
const w = watch(dir, { recursive: true }, done);
|
|
w.on("error", () => { /* ignore watch errors */ });
|
|
watchers.push(w);
|
|
} catch {
|
|
// directory might not exist yet
|
|
}
|
|
}
|
|
|
|
// Dashboard startup can complete without a source-file change after the CLI
|
|
// has already failed its first health check, so always keep a timed retry.
|
|
setTimeout(done, RETRY_TIMEOUT_MS);
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
while (true) {
|
|
const { code, signalled } = await runCliDev();
|
|
|
|
if (signalled || code === 0) {
|
|
stopChildren("SIGTERM");
|
|
process.exit(code);
|
|
}
|
|
|
|
log(`Dev command exited with code ${code}. Watching for file changes before retrying...`);
|
|
await waitForFileChanges();
|
|
log(`Retrying in ${RETRY_DEBOUNCE_MS / 1000}s...`);
|
|
await sleep(RETRY_DEBOUNCE_MS);
|
|
}
|
|
}
|
|
|
|
function stopChildren(signal) {
|
|
if (cliChild != null && !cliChild.killed) {
|
|
try {
|
|
if (cliChild.pid != null && process.platform !== "win32") {
|
|
process.kill(-cliChild.pid, signal);
|
|
} else {
|
|
cliChild.kill(signal);
|
|
}
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
}
|
|
|
|
process.on("SIGINT", () => {
|
|
stopChildren("SIGINT");
|
|
shutdownTimer ??= setTimeout(() => process.exit(130), 5_000);
|
|
shutdownTimer.unref();
|
|
});
|
|
process.on("SIGTERM", () => {
|
|
stopChildren("SIGTERM");
|
|
shutdownTimer ??= setTimeout(() => process.exit(143), 5_000);
|
|
shutdownTimer.unref();
|
|
});
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
stopChildren("SIGTERM");
|
|
process.exit(1);
|
|
});
|