Compare commits

...
2 changed files with 161 additions and 10 deletions
+32 -10
View File
@@ -15,8 +15,10 @@
import * as fs from "node:fs/promises"
import { Project, SyntaxKind } from "ts-morph"
const STATE_KEYS_PATH = "src/shared/storage/state-keys.ts"
const STATE_PROTO_PATH = "proto/cline/state.proto"
// Env overrides are primarily used by isolated regression tests so the
// generator can operate on temporary fixtures without touching repo files.
const STATE_KEYS_PATH = process.env.STATE_KEYS_PATH ?? "src/shared/storage/state-keys.ts"
const STATE_PROTO_PATH = process.env.STATE_PROTO_PATH ?? "proto/cline/state.proto"
/**
* Convert field name to valid snake_case proto field name.
@@ -230,6 +232,18 @@ function snakeToCamel(str) {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
}
/**
* Normalize a TypeScript/storage key so legacy hyphenated secret names can be
* matched against camelCase proto field names.
*/
function normalizeFieldLookupName(str) {
if (!str.includes("-")) {
return str
}
return str.replace(/-([a-zA-Z])/g, (_, letter) => letter.toUpperCase())
}
/**
* Parse field numbers from an existing proto message definition
* Returns a map of camelCase field names to their field numbers
@@ -248,12 +262,15 @@ function parseProtoMessageFieldNumbers(protoContent, messageName) {
const messageBody = match[1]
// Match field definitions: optional/required/repeated type name = number;
const fieldRegex = /(?:optional|required|repeated)?\s*\w+\s+(\w+)\s*=\s*(\d+)\s*;/g
// Includes proto map fields such as: map<string, string> open_ai_headers = 177;
// This parser is intentionally formatting-tolerant for the proto shapes we
// generate today, but still regex-based rather than a full proto parser.
const fieldRegex = /(?:optional|required|repeated)?\s*(map<[^>]+>|\w+)\s+(\w+)\s*=\s*(\d+)\s*;/g
const matches = messageBody.matchAll(fieldRegex)
for (const fieldMatch of matches) {
const snakeName = fieldMatch[1]
const fieldNum = Number.parseInt(fieldMatch[2], 10)
const snakeName = fieldMatch[2]
const fieldNum = Number.parseInt(fieldMatch[3], 10)
const camelName = snakeToCamel(snakeName)
fieldNumbers[camelName] = fieldNum
}
@@ -274,9 +291,13 @@ async function loadFieldNumbersFromProto() {
console.log(` Found ${Object.keys(settings).length} existing Settings fields`)
return { Secrets: secrets, Settings: settings }
} catch {
} catch (error) {
// Proto file doesn't exist, start fresh
return { Secrets: {}, Settings: {} }
if (error?.code === "ENOENT") {
return { Secrets: {}, Settings: {} }
}
throw error
}
}
@@ -296,8 +317,9 @@ function assignFieldNumbers(fields, existingNumbers, startNumber = 1) {
// Preserve existing assignments
for (const field of fields) {
if (existingNumbers[field.name] !== undefined) {
result[field.name] = existingNumbers[field.name]
const normalizedName = normalizeFieldLookupName(field.name)
if (existingNumbers[normalizedName] !== undefined) {
result[field.name] = existingNumbers[normalizedName]
}
}
@@ -356,7 +378,7 @@ function replaceMessage(protoContent, messageName, newMessageContent) {
}
// Message doesn't exist, append before the first message or at end
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
return protoContent + "\n\n" + newMessageContent
return `${protoContent}\n\n${newMessageContent}`
}
async function main() {
+129
View File
@@ -0,0 +1,129 @@
import { execFileSync } from "node:child_process"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { expect } from "chai"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const repoRoot = path.resolve(__dirname, "../..")
describe("generate-state-proto script", () => {
it("preserves field numbers for map fields and hyphenated secret names", function () {
this.timeout(10000)
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-state-proto-"))
try {
const stateKeysPath = path.join(tmpDir, "state-keys.ts")
const stateProtoPath = path.join(tmpDir, "state.proto")
fs.writeFileSync(
stateKeysPath,
[
"const API_HANDLER_SETTINGS_FIELDS = {",
" openAiHeaders: { default: {} as Record<string, string> },",
"} satisfies Record<string, { default: any }>",
"",
"const USER_SETTINGS_FIELDS = {} satisfies Record<string, { default: any }>",
"",
"const SECRETS_KEYS = [",
' "openai-codex-oauth-credentials",',
"] as const",
].join("\n"),
)
fs.writeFileSync(
stateProtoPath,
[
'syntax = "proto3";',
"package cline;",
"",
"message Secrets {",
" optional string openai_codex_oauth_credentials = 48;",
"}",
"",
"message Settings {",
" map<string, string> open_ai_headers = 177;",
"}",
].join("\n"),
)
execFileSync(process.execPath, [path.join(repoRoot, "scripts/generate-state-proto.mjs")], {
cwd: repoRoot,
env: {
...process.env,
STATE_KEYS_PATH: stateKeysPath,
STATE_PROTO_PATH: stateProtoPath,
},
})
const output = fs.readFileSync(stateProtoPath, "utf8")
expect(output).to.contain("optional string openai_codex_oauth_credentials = 48;")
expect(output).to.contain("map<string, string> open_ai_headers = 177;")
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})
it("preserves existing numbers while assigning the next available number to new fields", function () {
this.timeout(10000)
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "generate-state-proto-"))
try {
const stateKeysPath = path.join(tmpDir, "state-keys.ts")
const stateProtoPath = path.join(tmpDir, "state.proto")
fs.writeFileSync(
stateKeysPath,
[
"const API_HANDLER_SETTINGS_FIELDS = {",
" openAiHeaders: { default: {} as Record<string, string> },",
" anthropicBaseUrl: { default: undefined as string | undefined },",
"} satisfies Record<string, { default: any }>",
"",
"const USER_SETTINGS_FIELDS = {} satisfies Record<string, { default: any }>",
"",
"const SECRETS_KEYS = [",
' "openai-codex-oauth-credentials",',
' "apiKey",',
"] as const",
].join("\n"),
)
fs.writeFileSync(
stateProtoPath,
[
'syntax = "proto3";',
"package cline;",
"",
"message Secrets {",
" optional string openai_codex_oauth_credentials = 48;",
"}",
"",
"message Settings {",
" map<string,string> open_ai_headers = 177;",
"}",
].join("\n"),
)
execFileSync(process.execPath, [path.join(repoRoot, "scripts/generate-state-proto.mjs")], {
cwd: repoRoot,
env: {
...process.env,
STATE_KEYS_PATH: stateKeysPath,
STATE_PROTO_PATH: stateProtoPath,
},
})
const output = fs.readFileSync(stateProtoPath, "utf8")
expect(output).to.contain("optional string openai_codex_oauth_credentials = 48;")
expect(output).to.contain("optional string api_key = 49;")
expect(output).to.contain("map<string, string> open_ai_headers = 177;")
expect(output).to.contain("optional string anthropic_base_url = 178;")
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})
})