-
Notifications
You must be signed in to change notification settings - Fork 2
chore(dev): dev environment doctor + standardized dev commands + CI uv migration #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| 26.3.1 | ||
| 24 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| import { execFileSync } from 'node:child_process' | ||
| import { existsSync, readFileSync } from 'node:fs' | ||
| import path from 'node:path' | ||
|
|
||
| const root = process.cwd() | ||
| const args = process.argv.slice(2) | ||
| const envFileArg = args.find((arg) => arg.startsWith('--env-file=')) | ||
| const profilesArg = args.find((arg) => arg.startsWith('--profiles=')) | ||
| const envFile = path.resolve(root, envFileArg?.slice('--env-file='.length) || '.env') | ||
|
|
||
| function parseEnv(file) { | ||
| if (!existsSync(file)) return {} | ||
| const values = {} | ||
| for (const rawLine of readFileSync(file, 'utf8').split(/\r?\n/)) { | ||
| const line = rawLine.trim() | ||
| if (!line || line.startsWith('#')) continue | ||
| const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/) | ||
| if (!match) continue | ||
| let value = match[2].trim() | ||
| if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { | ||
| value = value.slice(1, -1) | ||
| } | ||
| values[match[1]] = value | ||
| } | ||
| return values | ||
| } | ||
|
|
||
| const fileEnv = parseEnv(envFile) | ||
| const config = { ...fileEnv, ...process.env } | ||
| const inferredProfiles = ['core'] | ||
| if (config.TASK_EXECUTOR === 'celery') inferredProfiles.push('celery') | ||
| if (config.COLLECTION_MODE === 'agent') inferredProfiles.push('agent') | ||
| if (config.CHROME_SUFFIX) inferredProfiles.push('embedded-chrome') | ||
| if (config.INVOKEAI_ENABLED === 'true') inferredProfiles.push('image-studio') | ||
| const explicitProfiles = profilesArg | ||
| ? profilesArg.slice('--profiles='.length).split(',').map((item) => item.trim()).filter(Boolean) | ||
| : [] | ||
| const requestedProfiles = [...inferredProfiles, ...explicitProfiles] | ||
| const profiles = [...new Set(['core', ...requestedProfiles])] | ||
| const errors = [] | ||
| const notes = [] | ||
|
|
||
| function value(name) { | ||
| return (config[name] || '').trim() | ||
| } | ||
|
|
||
| function requireValue(name, profile) { | ||
| const current = value(name) | ||
| if (!current) errors.push(`[${profile}] ${name} is required`) | ||
| return current | ||
| } | ||
|
|
||
| function requireOne(names, profile) { | ||
| if (!names.some((name) => value(name))) { | ||
| errors.push(`[${profile}] configure at least one of: ${names.join(', ')}`) | ||
| } | ||
| } | ||
|
|
||
| function requireExact(name, expected, profile) { | ||
| const current = requireValue(name, profile) | ||
| if (current && current !== expected) errors.push(`[${profile}] ${name} must be ${expected}`) | ||
| } | ||
|
|
||
| function checkCommand(command, commandArgs, label) { | ||
| try { | ||
| return execFileSync(command, commandArgs, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() | ||
| } catch { | ||
| errors.push(`[tools] ${label} is unavailable`) | ||
| return '' | ||
| } | ||
| } | ||
|
|
||
| const rules = { | ||
| core() { | ||
| if (!existsSync(envFile)) errors.push(`[core] environment file not found: ${envFile}`) | ||
| for (const name of ['API_AUTH_TOKEN', 'BOOTSTRAP_ADMIN_TOKEN', 'SECRET_KEY', 'CREDENTIAL_ENCRYPTION_KEY', 'DATABASE_URL']) { | ||
| requireValue(name, 'core') | ||
| } | ||
| if (['change-me', 'change-me-in-production'].includes(value('SECRET_KEY'))) { | ||
| errors.push('[core] SECRET_KEY still uses a placeholder') | ||
| } | ||
| const suffix = value('CHROME_SUFFIX') | ||
| if (suffix && suffix !== '-chrome') errors.push('[core] CHROME_SUFFIX must be empty or -chrome') | ||
| }, | ||
| celery() { | ||
| requireExact('TASK_EXECUTOR', 'celery', 'celery') | ||
| if (value('DATABASE_URL').includes('sqlite')) { | ||
| errors.push('[celery] DATABASE_URL must use PostgreSQL; SQLite is unsafe for distributed workers') | ||
| } | ||
| }, | ||
| postgres() { | ||
| if (!requireValue('DATABASE_URL', 'postgres').startsWith('postgresql')) { | ||
| errors.push('[postgres] DATABASE_URL must use a PostgreSQL driver') | ||
| } | ||
| for (const name of ['POSTGRES_DB', 'POSTGRES_USER', 'POSTGRES_PASSWORD']) requireValue(name, 'postgres') | ||
| }, | ||
| agent() { | ||
| requireValue('CENTRAL_API_URL', 'agent') | ||
| requireValue('API_AUTH_TOKEN', 'agent') | ||
| const registration = value('AGENT_REGISTER') || 'http' | ||
| if (!['http', 'ws'].includes(registration)) errors.push('[agent] AGENT_REGISTER must be http or ws') | ||
| if (registration === 'http') requireValue('AGENT_ADVERTISE_URL', 'agent') | ||
| }, | ||
| 'embedded-chrome'() { | ||
| requireExact('CHROME_SUFFIX', '-chrome', 'embedded-chrome') | ||
| }, | ||
| ai() { | ||
| requireOne(['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'], 'ai') | ||
| }, | ||
| dify() { | ||
| notes.push('[dify] uses the internal Graphon runtime URL unless DIFY_GRAPHON_RUNTIME_URL overrides it') | ||
| }, | ||
| kats() { | ||
| notes.push('[kats] uses the internal Kats runtime URL unless KATS_RUNTIME_URL overrides it') | ||
| }, | ||
| 'image-studio'() { | ||
| requireExact('INVOKEAI_ENABLED', 'true', 'image-studio') | ||
| const image = requireValue('INVOKEAI_ATTESTED_IMAGE', 'image-studio') | ||
| if (image && (!image.includes('@sha256:') || image.includes('0000000000000000'))) { | ||
| errors.push('[image-studio] INVOKEAI_ATTESTED_IMAGE must be an attested digest-pinned image') | ||
| } | ||
| requireValue('INVOKEAI_API_TOKEN', 'image-studio') | ||
| }, | ||
| } | ||
|
|
||
| for (const profile of profiles) { | ||
| if (!rules[profile]) errors.push(`[profiles] unknown profile: ${profile}`) | ||
| else rules[profile]() | ||
| } | ||
|
|
||
| const nodeVersion = process.versions.node | ||
| const expectedNode = existsSync(path.join(root, '.nvmrc')) ? readFileSync(path.join(root, '.nvmrc'), 'utf8').trim() : '' | ||
| if (expectedNode && nodeVersion.split('.')[0] !== expectedNode.split('.')[0]) { | ||
| errors.push(`[tools] Node ${expectedNode}.x required; active version is ${nodeVersion}`) | ||
| } | ||
| checkCommand('uv', ['--version'], 'uv') | ||
| checkCommand('uv', ['lock', '--check'], 'uv lock') | ||
| checkCommand('docker', ['compose', '--env-file', envFile, '-f', 'docker-compose.yml', '-f', 'docker-compose.build.yml', 'config', '--quiet'], 'Docker Compose configuration') | ||
|
Comment on lines
+136
to
+138
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Do not require Docker Compose in the default source-development check. Line 138 runs Docker Compose validation for every Run Compose validation only in a Docker-specific command or profile. Otherwise, list Docker Compose as a base prerequisite. 🤖 Prompt for AI Agents |
||
|
|
||
| for (const note of notes) console.log(`NOTE ${note}`) | ||
| if (errors.length) { | ||
| for (const error of errors) console.error(`ERROR ${error}`) | ||
| console.error(`Environment check failed for profiles: ${profiles.join(', ')}`) | ||
| process.exit(1) | ||
| } | ||
|
|
||
| console.log(`Environment ready: ${profiles.join(', ')}`) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import assert from 'node:assert/strict' | ||
| import { execFileSync, spawnSync } from 'node:child_process' | ||
| import { mkdtempSync, writeFileSync } from 'node:fs' | ||
| import { tmpdir } from 'node:os' | ||
| import path from 'node:path' | ||
| import test from 'node:test' | ||
|
|
||
| const root = process.cwd() | ||
| const script = path.join(root, 'scripts', 'dev-environment.mjs') | ||
| const validCore = `API_AUTH_TOKEN=a\nBOOTSTRAP_ADMIN_TOKEN=b\nSECRET_KEY=c\nCREDENTIAL_ENCRYPTION_KEY=d\nDATABASE_URL=sqlite+aiosqlite:///test.db\nCHROME_SUFFIX=\n` | ||
|
|
||
| function envFile(contents) { | ||
| const directory = mkdtempSync(path.join(tmpdir(), 'opencli-env-doctor-')) | ||
| const file = path.join(directory, '.env') | ||
| writeFileSync(file, contents) | ||
| return file | ||
| } | ||
|
|
||
| test('accepts the default core profile with an empty Chrome suffix', () => { | ||
| const output = execFileSync(process.execPath, [script, `--env-file=${envFile(validCore)}`], { | ||
| cwd: root, | ||
| encoding: 'utf8', | ||
| }) | ||
|
Comment on lines
+20
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Use a controlled child environment for environment-fixture tests. The validator merges
Preserve only runtime lookup variables that the child process needs, such as 📍 Affects 1 file
🤖 Prompt for AI Agents |
||
| assert.match(output, /Environment ready: core/) | ||
| }) | ||
|
|
||
| test('rejects embedded Chrome without the image suffix', () => { | ||
| const result = spawnSync(process.execPath, [script, `--env-file=${envFile(validCore)}`, '--profiles=embedded-chrome'], { | ||
| cwd: root, | ||
| encoding: 'utf8', | ||
| }) | ||
| assert.notEqual(result.status, 0) | ||
| assert.match(result.stderr, /CHROME_SUFFIX (?:is required|must be -chrome)/) | ||
| }) | ||
|
|
||
| test('rejects HTTP agent registration without an advertised URL', () => { | ||
| const file = envFile(`${validCore}CENTRAL_API_URL=http://center:8031\nAGENT_REGISTER=http\n`) | ||
| const result = spawnSync(process.execPath, [script, `--env-file=${file}`, '--profiles=agent'], { | ||
| cwd: root, | ||
| encoding: 'utf8', | ||
| }) | ||
| assert.notEqual(result.status, 0) | ||
| assert.match(result.stderr, /AGENT_ADVERTISE_URL is required/) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
GitHub Actions security hardened runners use full-length commit SHA for third-party actions💡 Result:
Pinning GitHub Actions to a full-length 40-character commit SHA is a security best practice for mitigating supply chain risks, particularly for third-party actions [1][2][3]. Because tags (e.g.,
@v1) and branches (e.g.,@main) are mutable references, they can be re-pointed by a compromised account to execute malicious code [2][3][4]. Pinning to a specific, full-length SHA ensures the code remains immutable, protecting workflows from silent, unauthorized updates [1][2][4]. Key aspects of this practice include: 1. Enforcement and Governance: Organizations can now mandate SHA pinning via GitHub's allowed actions policy [5][6]. When enabled, any workflow attempting to use an action not pinned to a full commit SHA will fail [5][6]. This policy evaluates the entire dependency tree, meaning nested sub-actions must also be pinned [6]. 2. Maintaining Updates: To ensure security patches are still received, it is standard practice to append the human-readable version as a trailing comment (e.g., uses: actions/checkout@<40-char-sha> # v4.2.0) [2][3][4]. Tools like Dependabot and Renovate are designed to parse these comments, allowing them to continue opening pull requests to update the SHA while maintaining the pinning requirement [2][3][4]. 3. Implementation: - Always use the full 40-character hexadecimal commit SHA, not a shortened version [3][4][7]. - For Docker-based actions, use the immutable@sha256digest [4][7]. - You can automate the enforcement of these requirements by using third-party workflow checks, such as step-security/github-actions-ensure-sha-pinned-actions, which will fail the build if unpinned actions are detected [8][4]. - While official GitHub-owned actions (actions/*) are considered lower risk, pinning them as well is recommended for consistency and a more rigorous security posture [3][4].Citations:
🌐 Web query:
astral-sh setup-uv releases v7 Git tag💡 Result:
The repository astral-sh/setup-uv does not have a single v7 Git tag; rather, it uses a series of versioned tags starting with the prefix "v7" (e.g., v7.1.1, v7.6.0) [1][2]. The "v7" range in the repository history represents a collection of commits and releases preceding later major versions, such as v8 and v9 [1][3][4][5]. As of August 8, 2026, the current major release series for setup-uv is v9 [1][5]. The project has moved away from publishing major and minor tags (such as
@v7,@v8, or@v9) to increase supply chain security, instead recommending the use of specific immutable tags (e.g., v9.0.0) or full Git hashes for pinning in GitHub Actions workflows [5][6]. For installing uv in GitHub Actions, the official recommendation is to use the astral-sh/setup-uv action [7]. A standard implementation example is [7]: - uses: astral-sh/setup-uv@c771a70 # v9.0.0 with: version: "0.12.2" The uv tool itself uses a different versioning scheme (e.g., v0.12.2) [8][9] and is distinct from the setup-uv action versioning [1].Citations:
🏁 Script executed:
Repository: 2233admin/opencli-Razormind
Length of output: 1118
Security Misconfiguration (CWE-494): Download of Code Without Integrity Check
Reachability: External
Pin both
setup-uvreferences to full commit SHAs.astral-sh/setup-uv@v7is a mutable reference, so workflow runs can execute unreviewed action code if it moves. Use a specific release tag or full-length commit SHA instead, for exampleastral-sh/setup-uv@<full-commit-sha> # v7.x.y.Also applies to: 241-242
🤖 Prompt for AI Agents