Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 73 additions & 3 deletions services/platform/scripts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,17 @@
*/

import { type ChildProcess, spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
} from 'node:fs';
import { createConnection } from 'node:net';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import process from 'node:process';

import kill from 'tree-kill';
Expand Down Expand Up @@ -55,6 +63,62 @@ function parseDotEnv(filePath: string): Record<string, string> {
return result;
}

// Personal, gitignored config dir for the local dev instance. The app
// mutates this on every provider/agent/workflow edit, so we MUST keep it
// outside the tracked tree — otherwise routine UI clicks bleed into
// `git status` and ride along on unrelated PRs (see #1729 fallout).
//
// Seeded once from the in-repo `examples/` starter on first run; never
// overwritten after that, so the user's edits persist across sessions
// and `git pull` updates to `examples/` don't clobber local state.
function seedDevConfigDir(): string {
const taleDir = join(homedir(), '.tale');
const target = join(taleDir, 'dev-config');
if (existsSync(target)) return target;

const source = join(repoRoot, 'examples');
if (!existsSync(source)) {
// No examples to seed from; just create an empty dir so the convex
// file_utils don't blow up on the first read.
mkdirSync(target, { recursive: true });
console.log(
`[dev] 📁 Created empty dev config dir (no examples/ to seed from): ${target}`,
);
return target;
}

// Copy into a sibling temp dir first, then atomically rename to the
// final target. Prevents a crashed/interrupted seed from leaving a
// half-populated `dev-config/` that the `existsSync` short-circuit
// would then treat as "already seeded" on the next run.
mkdirSync(taleDir, { recursive: true });
const tempTarget = join(
taleDir,
`dev-config.tmp.${process.pid}.${Date.now()}`,
);
try {
cpSync(source, tempTarget, {
recursive: true,
// Skip macOS metadata; use basename so it works on Windows paths
// (which use backslashes) as well as POSIX.
filter: (src) => basename(src) !== '.DS_Store',
});
renameSync(tempTarget, target);
} catch (err) {
// Best-effort cleanup; if rmSync also throws, surface the original.
try {
rmSync(tempTarget, { recursive: true, force: true });
} catch {
// ignore
}
throw err;
}
console.log(
`[dev] 📁 Seeded dev config from ${source} → ${target} (one-time; your edits stay local)`,
);
return target;
}
Comment on lines +74 to +120
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff

Seeding is not truly atomic as described in PR objectives.

If cpSync fails partway through (e.g., disk full, permission error), the target directory will contain partial content. On the next run, existsSync(target) returns true (line 69), so the seed is skipped, leaving the developer with an incomplete config directory.

For a dev script, this may be acceptable—developers can manually delete ~/.tale/dev-config and re-run. If true atomicity is required, consider copying to a temporary directory first, then renaming it to the final location in a single operation.

💡 Approach for atomic seeding
const temp = join(homedir(), '.tale', `dev-config.tmp.${Date.now()}`);
mkdirSync(join(homedir(), '.tale'), { recursive: true });
cpSync(source, temp, {
  recursive: true,
  filter: (src) => basename(src) !== '.DS_Store',
});
// Atomic rename (fails if target exists)
renameSync(temp, target);

Requires importing renameSync from node:fs.

🤖 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 `@services/platform/scripts/dev.ts` around lines 67 - 92, The seedDevConfigDir
function currently copies directly into target with cpSync so a partial copy
leaves an incomplete ~/.tale/dev-config on failure; change the flow to copy into
a temp directory (e.g., join(homedir(), '.tale',
`dev-config.tmp.${Date.now()}`)) under the same parent, using cpSync with the
same options, then atomically rename/replace temp → target using renameSync;
ensure you create the parent dir with mkdirSync before copying, catch and
cleanup the temp dir on errors, and keep the existsSync(target) short-circuit
unchanged so subsequent runs see the final atomic rename.


function envNormalizeCommon() {
process.env.NODE_ENV = 'development';
if (!process.env.PORT) process.env.PORT = '3000';
Expand All @@ -71,9 +135,15 @@ function envNormalizeCommon() {

// Root config directory only — Convex derives sub-dirs (agents/workflows/
// integrations/providers) from TALE_CONFIG_DIR via `convex/*/file_utils.ts`.
//
// Default to a personal gitignored dir seeded from `examples/` so the
// tracked tree is never the runtime write target. Override by setting
// TALE_CONFIG_DIR in `.env.local` if you want a different location
// (e.g. a checked-out customer config).
if (!process.env.TALE_CONFIG_DIR) {
process.env.TALE_CONFIG_DIR = join(repoRoot, 'examples');
process.env.TALE_CONFIG_DIR = seedDevConfigDir();
}
console.log(`[dev] 📂 TALE_CONFIG_DIR=${process.env.TALE_CONFIG_DIR}`);
}

function ensureInstanceSecret() {
Expand Down
Loading