mirror of
https://github.com/schpet/linear-cli.git
synced 2026-09-14 14:26:50 +08:00
9058328e4c
#266 adds `-T/--template` and a `pr_template` config option, which are the right surface -- the names and the precedence are kept exactly as the contributor designed them. The mechanism cannot work, though, and I could not find a variant of it that does. The command already passes `--body <issue url>`, and #266 appends `--template` next to it. `gh` refuses that pair outright: `--template` is not supported when using `--body` or `--body-file` So every use of the new flag fails, and setting `pr_template` in config breaks `issue pr` on every invocation rather than only when the flag is passed. Dropping `--body` to make room for `--template` -- the obvious repair -- is worse. `gh` only consults a template when it is running interactively; without a body a non-TTY caller gets must provide `--title` and `--body` (or `--fill` ...) when not running interactively and no pull request at all. That would trade a broken flag for a command broken in CI, scripts, and agents. Handing `gh` a temporary file that already contains the template fails the same way, because the problem is the missing `--body`, not the file's contents. So the template is read here and folded into the body we already send, with the issue URL appended after it. The URL is what Linear matches on to attach the pull request to its issue, so it has to survive; putting it last leaves the template's prose as the first thing a reviewer reads. Every existing flag keeps working, because the argv shape is unchanged. Reading the file ourselves means we own its failures, and per CLAUDE.md an explicitly requested template that cannot be used is an error rather than a silent fallback to a URL-only body -- otherwise the user gets a pull request quietly missing the content they asked for. Missing paths, directories, non-regular files and unreadable files all produce a message naming the path. NUL bytes are rejected too: `Deno.readTextFile` does not refuse binary input, it substitutes U+FFFD and keeps the NULs, which `Deno.Command` then rejects with a bare TypeError that never mentions the file. One deliberate surface change: #266's description suggests `-T ""` to override a configured default. That worked only because an empty string happened to be falsy. It is now an explicit `--no-template` flag, and `-T ""` errors with a suggestion pointing at it. The generated skill docs under skills/ are left alone; they are produced from an installed binary out of band and are already stale on trunk.
1218 lines
40 KiB
TypeScript
1218 lines
40 KiB
TypeScript
import { assertEquals, assertStringIncludes, assertThrows } from "@std/assert"
|
|
import { fromFileUrl } from "@std/path"
|
|
import {
|
|
getOption,
|
|
getOptionWithSource,
|
|
resolveIssueSort,
|
|
resolvePrTemplate,
|
|
} from "../src/config.ts"
|
|
import { ValidationError } from "../src/utils/errors.ts"
|
|
|
|
// Note: These tests use the cliValue parameter (highest precedence)
|
|
// to avoid interference from config files that may exist in the repo
|
|
|
|
Deno.test("getOption - download_images returns boolean for truthy strings", () => {
|
|
const truthyValues = [
|
|
"true",
|
|
"TRUE",
|
|
"True",
|
|
"yes",
|
|
"YES",
|
|
"y",
|
|
"Y",
|
|
"on",
|
|
"ON",
|
|
"1",
|
|
"t",
|
|
"T",
|
|
]
|
|
|
|
for (const value of truthyValues) {
|
|
const result = getOption("download_images", value)
|
|
assertEquals(result, true, `Expected "${value}" to coerce to true`)
|
|
}
|
|
})
|
|
|
|
Deno.test("getOption - download_images returns boolean for falsy strings", () => {
|
|
const falsyValues = [
|
|
"false",
|
|
"FALSE",
|
|
"False",
|
|
"no",
|
|
"NO",
|
|
"n",
|
|
"N",
|
|
"off",
|
|
"OFF",
|
|
"0",
|
|
"f",
|
|
"F",
|
|
]
|
|
|
|
for (const value of falsyValues) {
|
|
const result = getOption("download_images", value)
|
|
assertEquals(result, false, `Expected "${value}" to coerce to false`)
|
|
}
|
|
})
|
|
|
|
Deno.test("getOption - download_images returns undefined for unrecognized strings", () => {
|
|
const result = getOption("download_images", "maybe")
|
|
assertEquals(result, undefined)
|
|
})
|
|
|
|
Deno.test("getOption - issue_create_ask_project returns boolean for truthy strings", () => {
|
|
const truthyValues = ["true", "yes", "1", "on", "t"]
|
|
|
|
for (const value of truthyValues) {
|
|
const result = getOption("issue_create_ask_project", value)
|
|
assertEquals(result, true, `Expected "${value}" to coerce to true`)
|
|
}
|
|
})
|
|
|
|
Deno.test("getOption - issue_create_ask_project returns boolean for falsy strings", () => {
|
|
const falsyValues = ["false", "no", "0", "off", "f"]
|
|
|
|
for (const value of falsyValues) {
|
|
const result = getOption("issue_create_ask_project", value)
|
|
assertEquals(result, false, `Expected "${value}" to coerce to false`)
|
|
}
|
|
})
|
|
|
|
Deno.test("getOption - issue_create_assign_self accepts valid mode values", () => {
|
|
const validValues = ["always", "auto", "never"] as const
|
|
|
|
for (const value of validValues) {
|
|
const result = getOption("issue_create_assign_self", value)
|
|
assertEquals(result, value)
|
|
}
|
|
})
|
|
|
|
Deno.test("getOption - issue_create_assign_self rejects invalid mode values", () => {
|
|
const result = getOption("issue_create_assign_self", "true")
|
|
assertEquals(result, undefined)
|
|
})
|
|
|
|
Deno.test("getOption - environment variables take precedence over config file", async () => {
|
|
// Create a temp directory with a config file
|
|
const tempDir = await Deno.makeTempDir()
|
|
const configValue = "from-config-file"
|
|
const envValue = "from-env-var"
|
|
|
|
try {
|
|
// Write a .linear.toml with a workspace value
|
|
await Deno.writeTextFile(
|
|
`${tempDir}/.linear.toml`,
|
|
`workspace = "${configValue}"\n`,
|
|
)
|
|
|
|
// Get absolute paths to the config module and deno.json
|
|
const configUrl = new URL("../src/config.ts", import.meta.url)
|
|
const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url))
|
|
|
|
// Run a subprocess that imports config and prints the workspace value
|
|
// The subprocess runs from the temp directory so it loads our test config
|
|
const command = new Deno.Command("deno", {
|
|
args: [
|
|
"eval",
|
|
`--config=${denoJsonPath}`,
|
|
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
|
|
],
|
|
cwd: tempDir,
|
|
env: {
|
|
LINEAR_WORKSPACE: envValue,
|
|
},
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
})
|
|
|
|
const { stdout, stderr } = await command.output()
|
|
const output = new TextDecoder().decode(stdout).trim()
|
|
const errorOutput = new TextDecoder().decode(stderr)
|
|
|
|
if (errorOutput) {
|
|
console.error("Subprocess stderr:", errorOutput)
|
|
}
|
|
|
|
// The env var should win over the config file
|
|
assertEquals(
|
|
output,
|
|
envValue,
|
|
"Environment variable should take precedence over config file",
|
|
)
|
|
} finally {
|
|
// Clean up temp directory
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOption - config file is used when no env var is set", async () => {
|
|
// Create a temp directory with a config file
|
|
const tempDir = await Deno.makeTempDir()
|
|
const configValue = "from-config-file"
|
|
|
|
try {
|
|
// Write a .linear.toml with a workspace value
|
|
await Deno.writeTextFile(
|
|
`${tempDir}/.linear.toml`,
|
|
`workspace = "${configValue}"\n`,
|
|
)
|
|
|
|
// Get absolute paths to the config module and deno.json
|
|
const configUrl = new URL("../src/config.ts", import.meta.url)
|
|
const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url))
|
|
|
|
// Run a subprocess without LINEAR_WORKSPACE env var
|
|
// Include essential env vars for subprocess to work correctly
|
|
const command = new Deno.Command("deno", {
|
|
args: [
|
|
"eval",
|
|
`--config=${denoJsonPath}`,
|
|
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
|
|
],
|
|
cwd: tempDir,
|
|
env: {
|
|
PATH: Deno.env.get("PATH") ?? "",
|
|
...(Deno.build.os === "windows"
|
|
? { SystemRoot: Deno.env.get("SystemRoot") ?? "" }
|
|
: {}),
|
|
},
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
})
|
|
|
|
const { stdout, stderr } = await command.output()
|
|
const output = new TextDecoder().decode(stdout).trim()
|
|
const errorOutput = new TextDecoder().decode(stderr)
|
|
|
|
if (errorOutput) {
|
|
console.error("Subprocess stderr:", errorOutput)
|
|
}
|
|
|
|
// The config file value should be used as fallback
|
|
assertEquals(
|
|
output,
|
|
configValue,
|
|
"Config file should be used when no env var is set",
|
|
)
|
|
} finally {
|
|
// Clean up temp directory
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOption - home folder config is used as fallback", async () => {
|
|
// Create a temp directory structure simulating a home folder
|
|
const tempHome = await Deno.makeTempDir()
|
|
const homeConfigValue = "from-home-config"
|
|
|
|
try {
|
|
// Create config file in platform-appropriate location
|
|
const isWindows = Deno.build.os === "windows"
|
|
if (isWindows) {
|
|
// Windows: %APPDATA%\linear\linear.toml
|
|
await Deno.mkdir(`${tempHome}/linear`, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${tempHome}/linear/linear.toml`,
|
|
`workspace = "${homeConfigValue}"\n`,
|
|
)
|
|
} else {
|
|
// Unix: ~/.config/linear/linear.toml
|
|
await Deno.mkdir(`${tempHome}/.config/linear`, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${tempHome}/.config/linear/linear.toml`,
|
|
`workspace = "${homeConfigValue}"\n`,
|
|
)
|
|
}
|
|
|
|
// Create a separate temp directory to run from (no project config)
|
|
const workDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configUrl = new URL("../src/config.ts", import.meta.url)
|
|
const denoJsonPath = fromFileUrl(
|
|
new URL("../deno.json", import.meta.url),
|
|
)
|
|
|
|
// Run subprocess with appropriate env var for platform
|
|
// Note: Must NOT include XDG_CONFIG_HOME so HOME/.config is used
|
|
const env: Record<string, string> = isWindows
|
|
? { APPDATA: tempHome }
|
|
: { HOME: tempHome, PATH: Deno.env.get("PATH") ?? "" }
|
|
const command = new Deno.Command("deno", {
|
|
args: [
|
|
"eval",
|
|
`--config=${denoJsonPath}`,
|
|
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
|
|
],
|
|
cwd: workDir,
|
|
env,
|
|
clearEnv: true,
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
})
|
|
|
|
const { stdout, stderr } = await command.output()
|
|
const output = new TextDecoder().decode(stdout).trim()
|
|
const errorOutput = new TextDecoder().decode(stderr)
|
|
|
|
if (errorOutput) {
|
|
console.error("Subprocess stderr:", errorOutput)
|
|
}
|
|
|
|
assertEquals(
|
|
output,
|
|
homeConfigValue,
|
|
"Home folder config should be used when no project config exists",
|
|
)
|
|
} finally {
|
|
await Deno.remove(workDir, { recursive: true })
|
|
}
|
|
} finally {
|
|
await Deno.remove(tempHome, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOption - project config takes precedence over home config", async () => {
|
|
// Create temp directories for home and project
|
|
const tempHome = await Deno.makeTempDir()
|
|
const projectDir = await Deno.makeTempDir()
|
|
const homeConfigValue = "from-home-config"
|
|
const projectConfigValue = "from-project-config"
|
|
|
|
try {
|
|
// Create home config in platform-appropriate location
|
|
const isWindows = Deno.build.os === "windows"
|
|
if (isWindows) {
|
|
await Deno.mkdir(`${tempHome}/linear`, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${tempHome}/linear/linear.toml`,
|
|
`workspace = "${homeConfigValue}"\n`,
|
|
)
|
|
} else {
|
|
// Unix: ~/.config/linear/linear.toml
|
|
await Deno.mkdir(`${tempHome}/.config/linear`, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${tempHome}/.config/linear/linear.toml`,
|
|
`workspace = "${homeConfigValue}"\n`,
|
|
)
|
|
}
|
|
|
|
// Create project .linear.toml
|
|
await Deno.writeTextFile(
|
|
`${projectDir}/.linear.toml`,
|
|
`workspace = "${projectConfigValue}"\n`,
|
|
)
|
|
|
|
const configUrl = new URL("../src/config.ts", import.meta.url)
|
|
const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url))
|
|
|
|
const env: Record<string, string> = isWindows
|
|
? { APPDATA: tempHome, SystemRoot: Deno.env.get("SystemRoot") ?? "" }
|
|
: { HOME: tempHome, PATH: Deno.env.get("PATH") ?? "" }
|
|
const command = new Deno.Command("deno", {
|
|
args: [
|
|
"eval",
|
|
`--config=${denoJsonPath}`,
|
|
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
|
|
],
|
|
cwd: projectDir,
|
|
env,
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
})
|
|
|
|
const { stdout, stderr } = await command.output()
|
|
const output = new TextDecoder().decode(stdout).trim()
|
|
const errorOutput = new TextDecoder().decode(stderr)
|
|
|
|
if (errorOutput) {
|
|
console.error("Subprocess stderr:", errorOutput)
|
|
}
|
|
|
|
assertEquals(
|
|
output,
|
|
projectConfigValue,
|
|
"Project config should take precedence over home config",
|
|
)
|
|
} finally {
|
|
await Deno.remove(tempHome, { recursive: true })
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test({
|
|
name: "getOption - XDG_CONFIG_HOME takes precedence over HOME/.config",
|
|
ignore: Deno.build.os === "windows", // XDG is Unix-specific
|
|
fn: async () => {
|
|
// Create temp directories for XDG and regular home
|
|
const tempHome = await Deno.makeTempDir()
|
|
const xdgConfigDir = await Deno.makeTempDir()
|
|
const homeConfigValue = "from-home-config"
|
|
const xdgConfigValue = "from-xdg-config"
|
|
|
|
try {
|
|
// Create ~/.config/linear/linear.toml (should NOT be used)
|
|
await Deno.mkdir(`${tempHome}/.config/linear`, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${tempHome}/.config/linear/linear.toml`,
|
|
`workspace = "${homeConfigValue}"\n`,
|
|
)
|
|
|
|
// Create $XDG_CONFIG_HOME/linear/linear.toml (should be used)
|
|
await Deno.mkdir(`${xdgConfigDir}/linear`, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${xdgConfigDir}/linear/linear.toml`,
|
|
`workspace = "${xdgConfigValue}"\n`,
|
|
)
|
|
|
|
// Create a work directory (no project config)
|
|
const workDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configUrl = new URL("../src/config.ts", import.meta.url)
|
|
const denoJsonPath = fromFileUrl(
|
|
new URL("../deno.json", import.meta.url),
|
|
)
|
|
|
|
const command = new Deno.Command("deno", {
|
|
args: [
|
|
"eval",
|
|
`--config=${denoJsonPath}`,
|
|
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
|
|
],
|
|
cwd: workDir,
|
|
env: {
|
|
HOME: tempHome,
|
|
XDG_CONFIG_HOME: xdgConfigDir,
|
|
PATH: Deno.env.get("PATH") ?? "",
|
|
},
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
})
|
|
|
|
const { stdout, stderr } = await command.output()
|
|
const output = new TextDecoder().decode(stdout).trim()
|
|
const errorOutput = new TextDecoder().decode(stderr)
|
|
|
|
if (errorOutput) {
|
|
console.error("Subprocess stderr:", errorOutput)
|
|
}
|
|
|
|
assertEquals(
|
|
output,
|
|
xdgConfigValue,
|
|
"XDG_CONFIG_HOME should take precedence over HOME/.config",
|
|
)
|
|
} finally {
|
|
await Deno.remove(workDir, { recursive: true })
|
|
}
|
|
} finally {
|
|
await Deno.remove(tempHome, { recursive: true })
|
|
await Deno.remove(xdgConfigDir, { recursive: true })
|
|
}
|
|
},
|
|
})
|
|
|
|
Deno.test({
|
|
name: "getOption - APPDATA config is used on Windows",
|
|
ignore: Deno.build.os !== "windows", // Windows-specific test
|
|
fn: async () => {
|
|
// Create temp directory simulating APPDATA
|
|
const tempAppData = await Deno.makeTempDir()
|
|
const configValue = "from-appdata-config"
|
|
|
|
try {
|
|
// Create %APPDATA%\linear\linear.toml
|
|
await Deno.mkdir(`${tempAppData}/linear`, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${tempAppData}/linear/linear.toml`,
|
|
`workspace = "${configValue}"\n`,
|
|
)
|
|
|
|
// Create a work directory (no project config)
|
|
const workDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configUrl = new URL("../src/config.ts", import.meta.url)
|
|
const denoJsonPath = fromFileUrl(
|
|
new URL("../deno.json", import.meta.url),
|
|
)
|
|
|
|
const command = new Deno.Command("deno", {
|
|
args: [
|
|
"eval",
|
|
`--config=${denoJsonPath}`,
|
|
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
|
|
],
|
|
cwd: workDir,
|
|
env: {
|
|
APPDATA: tempAppData,
|
|
SystemRoot: Deno.env.get("SystemRoot") ?? "",
|
|
},
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
})
|
|
|
|
const { stdout, stderr } = await command.output()
|
|
const output = new TextDecoder().decode(stdout).trim()
|
|
const errorOutput = new TextDecoder().decode(stderr)
|
|
|
|
if (errorOutput) {
|
|
console.error("Subprocess stderr:", errorOutput)
|
|
}
|
|
|
|
assertEquals(
|
|
output,
|
|
configValue,
|
|
"APPDATA config should be used on Windows",
|
|
)
|
|
} finally {
|
|
await Deno.remove(workDir, { recursive: true })
|
|
}
|
|
} finally {
|
|
await Deno.remove(tempAppData, { recursive: true })
|
|
}
|
|
},
|
|
})
|
|
|
|
Deno.test("getOption - global and project configs are merged", async () => {
|
|
// Create temp directories for home and project
|
|
const tempHome = await Deno.makeTempDir()
|
|
const projectDir = await Deno.makeTempDir()
|
|
const globalIssueSort = "priority"
|
|
const projectWorkspace = "my-workspace"
|
|
|
|
try {
|
|
// Create home config with issue_sort
|
|
const isWindows = Deno.build.os === "windows"
|
|
if (isWindows) {
|
|
await Deno.mkdir(`${tempHome}/linear`, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${tempHome}/linear/linear.toml`,
|
|
`issue_sort = "${globalIssueSort}"\n`,
|
|
)
|
|
} else {
|
|
await Deno.mkdir(`${tempHome}/.config/linear`, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${tempHome}/.config/linear/linear.toml`,
|
|
`issue_sort = "${globalIssueSort}"\n`,
|
|
)
|
|
}
|
|
|
|
// Create project config with workspace (different key)
|
|
await Deno.writeTextFile(
|
|
`${projectDir}/.linear.toml`,
|
|
`workspace = "${projectWorkspace}"\n`,
|
|
)
|
|
|
|
const configUrl = new URL("../src/config.ts", import.meta.url)
|
|
const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url))
|
|
|
|
// Note: clearEnv ensures XDG_CONFIG_HOME doesn't interfere with HOME/.config
|
|
const env: Record<string, string> = isWindows
|
|
? { APPDATA: tempHome, SystemRoot: Deno.env.get("SystemRoot") ?? "" }
|
|
: { HOME: tempHome, PATH: Deno.env.get("PATH") ?? "" }
|
|
|
|
// Test that both values are accessible
|
|
const command = new Deno.Command("deno", {
|
|
args: [
|
|
"eval",
|
|
`--config=${denoJsonPath}`,
|
|
`import { getOption } from "${configUrl}"; console.log(JSON.stringify({ issue_sort: getOption("issue_sort"), workspace: getOption("workspace") }));`,
|
|
],
|
|
cwd: projectDir,
|
|
env,
|
|
clearEnv: true,
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
})
|
|
|
|
const { stdout, stderr } = await command.output()
|
|
const output = new TextDecoder().decode(stdout).trim()
|
|
const errorOutput = new TextDecoder().decode(stderr)
|
|
|
|
if (errorOutput) {
|
|
console.error("Subprocess stderr:", errorOutput)
|
|
}
|
|
|
|
const result = JSON.parse(output)
|
|
assertEquals(
|
|
result.issue_sort,
|
|
globalIssueSort,
|
|
"Global config value (issue_sort) should be accessible",
|
|
)
|
|
assertEquals(
|
|
result.workspace,
|
|
projectWorkspace,
|
|
"Project config value (workspace) should be accessible",
|
|
)
|
|
} finally {
|
|
await Deno.remove(tempHome, { recursive: true })
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOption - env var takes precedence over home config", async () => {
|
|
// Create temp home directory
|
|
const tempHome = await Deno.makeTempDir()
|
|
const homeConfigValue = "from-home-config"
|
|
const envValue = "from-env-var"
|
|
|
|
try {
|
|
// Create home config in platform-appropriate location
|
|
const isWindows = Deno.build.os === "windows"
|
|
if (isWindows) {
|
|
await Deno.mkdir(`${tempHome}/linear`, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${tempHome}/linear/linear.toml`,
|
|
`workspace = "${homeConfigValue}"\n`,
|
|
)
|
|
} else {
|
|
// Unix: ~/.config/linear/linear.toml
|
|
await Deno.mkdir(`${tempHome}/.config/linear`, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${tempHome}/.config/linear/linear.toml`,
|
|
`workspace = "${homeConfigValue}"\n`,
|
|
)
|
|
}
|
|
|
|
// Create a work directory (no project config)
|
|
const workDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configUrl = new URL("../src/config.ts", import.meta.url)
|
|
const denoJsonPath = fromFileUrl(
|
|
new URL("../deno.json", import.meta.url),
|
|
)
|
|
|
|
const env: Record<string, string> = isWindows
|
|
? {
|
|
APPDATA: tempHome,
|
|
LINEAR_WORKSPACE: envValue,
|
|
SystemRoot: Deno.env.get("SystemRoot") ?? "",
|
|
}
|
|
: {
|
|
HOME: tempHome,
|
|
LINEAR_WORKSPACE: envValue,
|
|
PATH: Deno.env.get("PATH") ?? "",
|
|
}
|
|
const command = new Deno.Command("deno", {
|
|
args: [
|
|
"eval",
|
|
`--config=${denoJsonPath}`,
|
|
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
|
|
],
|
|
cwd: workDir,
|
|
env,
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
})
|
|
|
|
const { stdout, stderr } = await command.output()
|
|
const output = new TextDecoder().decode(stdout).trim()
|
|
const errorOutput = new TextDecoder().decode(stderr)
|
|
|
|
if (errorOutput) {
|
|
console.error("Subprocess stderr:", errorOutput)
|
|
}
|
|
|
|
assertEquals(
|
|
output,
|
|
envValue,
|
|
"Environment variable should take precedence over home config",
|
|
)
|
|
} finally {
|
|
await Deno.remove(workDir, { recursive: true })
|
|
}
|
|
} finally {
|
|
await Deno.remove(tempHome, { recursive: true })
|
|
}
|
|
})
|
|
|
|
// --- resolveIssueSort ---
|
|
// Note: the repo's own .linear.toml sets issue_sort = "priority", so it is in
|
|
// the loaded config for in-process tests. Env vars are read at call time, so
|
|
// setting LINEAR_ISSUE_SORT here exercises precedence over that config value.
|
|
// The truly-unconfigured default is tested in a subprocess below.
|
|
|
|
Deno.test("resolveIssueSort - cli value takes precedence", () => {
|
|
assertEquals(resolveIssueSort("manual"), "manual")
|
|
assertEquals(resolveIssueSort("priority"), "priority")
|
|
})
|
|
|
|
Deno.test("resolveIssueSort - env var takes precedence over config", () => {
|
|
Deno.env.set("LINEAR_ISSUE_SORT", "manual")
|
|
try {
|
|
assertEquals(resolveIssueSort(), "manual")
|
|
} finally {
|
|
Deno.env.delete("LINEAR_ISSUE_SORT")
|
|
}
|
|
})
|
|
|
|
Deno.test("resolveIssueSort - invalid cli value throws", () => {
|
|
assertThrows(
|
|
() => resolveIssueSort("banana"),
|
|
ValidationError,
|
|
'Invalid issue sort: "banana"',
|
|
)
|
|
})
|
|
|
|
Deno.test("resolveIssueSort - invalid env value throws instead of defaulting", () => {
|
|
Deno.env.set("LINEAR_ISSUE_SORT", "banana")
|
|
try {
|
|
assertThrows(
|
|
() => resolveIssueSort(),
|
|
ValidationError,
|
|
'Invalid issue sort: "banana"',
|
|
)
|
|
} finally {
|
|
Deno.env.delete("LINEAR_ISSUE_SORT")
|
|
}
|
|
})
|
|
|
|
Deno.test("resolveIssueSort - empty env value throws instead of defaulting", () => {
|
|
Deno.env.set("LINEAR_ISSUE_SORT", "")
|
|
try {
|
|
assertThrows(
|
|
() => resolveIssueSort(),
|
|
ValidationError,
|
|
'Invalid issue sort: ""',
|
|
)
|
|
} finally {
|
|
Deno.env.delete("LINEAR_ISSUE_SORT")
|
|
}
|
|
})
|
|
|
|
Deno.test("resolveIssueSort - defaults to priority when nothing is configured", async () => {
|
|
// Subprocess with a cleared env and a temp cwd and HOME so no config file
|
|
// or env var (including one set in the test runner's environment) can
|
|
// supply issue_sort.
|
|
const tempDir = await Deno.makeTempDir()
|
|
try {
|
|
const configUrl = new URL("../src/config.ts", import.meta.url)
|
|
const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url))
|
|
const homeDir = Deno.env.get("HOME")
|
|
const denoDir = Deno.env.get("DENO_DIR") ??
|
|
(homeDir == null ? undefined : `${homeDir}/.cache/deno`)
|
|
const command = new Deno.Command(Deno.execPath(), {
|
|
args: [
|
|
"eval",
|
|
`--config=${denoJsonPath}`,
|
|
`import { resolveIssueSort } from "${configUrl}"; console.log(resolveIssueSort());`,
|
|
],
|
|
cwd: tempDir,
|
|
clearEnv: true,
|
|
env: {
|
|
HOME: tempDir,
|
|
XDG_CONFIG_HOME: `${tempDir}/.config`,
|
|
PATH: Deno.env.get("PATH") ?? "",
|
|
...(denoDir == null ? {} : { DENO_DIR: denoDir }),
|
|
...(Deno.build.os === "windows"
|
|
? { SystemRoot: Deno.env.get("SystemRoot") ?? "" }
|
|
: {}),
|
|
},
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
})
|
|
|
|
const { stdout, stderr } = await command.output()
|
|
const output = new TextDecoder().decode(stdout).trim()
|
|
const errorOutput = new TextDecoder().decode(stderr)
|
|
|
|
if (errorOutput) {
|
|
console.error("Subprocess stderr:", errorOutput)
|
|
}
|
|
|
|
assertEquals(output, "priority")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
// --- getOptionWithSource provenance ---
|
|
// These subprocesses use clearEnv so a developer's or CI's LINEAR_TEAM_ID
|
|
// cannot leak in; module-init config loading depends precisely on the
|
|
// environment at startup. On Windows, APPDATA is pointed at the same
|
|
// directory as XDG_CONFIG_HOME so one global config file covers both
|
|
// platforms' lookup paths.
|
|
|
|
async function initGitRepo(dir: string): Promise<void> {
|
|
const { success } = await new Deno.Command("git", {
|
|
args: ["init", "--quiet"],
|
|
cwd: dir,
|
|
stdout: "null",
|
|
stderr: "null",
|
|
}).output()
|
|
if (!success) throw new Error(`git init failed in ${dir}`)
|
|
}
|
|
|
|
interface TeamSourceRun {
|
|
result: unknown
|
|
stderr: string
|
|
}
|
|
|
|
/**
|
|
* Like runTeamSourceSubprocess, but returns stderr instead of echoing it, so
|
|
* tests can assert on the startup warnings config.ts emits there.
|
|
*/
|
|
async function runTeamSourceSubprocessRaw(options: {
|
|
cwd: string
|
|
home: string
|
|
extraEnv?: Record<string, string>
|
|
}): Promise<TeamSourceRun> {
|
|
const configUrl = new URL("../src/config.ts", import.meta.url)
|
|
const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url))
|
|
const homeDir = Deno.env.get("HOME")
|
|
const denoDir = Deno.env.get("DENO_DIR") ??
|
|
(homeDir == null ? undefined : `${homeDir}/.cache/deno`)
|
|
const command = new Deno.Command(Deno.execPath(), {
|
|
args: [
|
|
"eval",
|
|
`--config=${denoJsonPath}`,
|
|
`import { getOptionWithSource } from "${configUrl}"; console.log(JSON.stringify(getOptionWithSource("team_id") ?? null));`,
|
|
],
|
|
cwd: options.cwd,
|
|
clearEnv: true,
|
|
env: {
|
|
HOME: options.home,
|
|
XDG_CONFIG_HOME: `${options.home}/.config`,
|
|
PATH: Deno.env.get("PATH") ?? "",
|
|
// Keep startup warnings free of ANSI escapes so tests can match on text.
|
|
NO_COLOR: "1",
|
|
...(denoDir == null ? {} : { DENO_DIR: denoDir }),
|
|
...(Deno.build.os === "windows"
|
|
? {
|
|
SystemRoot: Deno.env.get("SystemRoot") ?? "",
|
|
APPDATA: `${options.home}/.config`,
|
|
}
|
|
: {}),
|
|
...options.extraEnv,
|
|
},
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
// A .env that makes module init hang would otherwise wedge the suite
|
|
// forever rather than fail; see the shell-variable test below.
|
|
signal: AbortSignal.timeout(60_000),
|
|
})
|
|
const { stdout, stderr } = await command.output()
|
|
return {
|
|
result: JSON.parse(new TextDecoder().decode(stdout).trim()),
|
|
stderr: new TextDecoder().decode(stderr),
|
|
}
|
|
}
|
|
|
|
async function runTeamSourceSubprocess(options: {
|
|
cwd: string
|
|
home: string
|
|
extraEnv?: Record<string, string>
|
|
}): Promise<unknown> {
|
|
const { result, stderr } = await runTeamSourceSubprocessRaw(options)
|
|
if (stderr) {
|
|
console.error("Subprocess stderr:", stderr)
|
|
}
|
|
return result
|
|
}
|
|
|
|
Deno.test("getOptionWithSource - project config file yields project-config source", async () => {
|
|
const projectDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.writeTextFile(`${projectDir}/.linear.toml`, 'team_id = "ENG"\n')
|
|
const result = await runTeamSourceSubprocess({ cwd: projectDir, home })
|
|
assertEquals(result, { value: "ENG", source: "project-config" })
|
|
} finally {
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - global config file yields global-config source", async () => {
|
|
const workDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.mkdir(`${home}/.config/linear`, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${home}/.config/linear/linear.toml`,
|
|
'team_id = "ENG"\n',
|
|
)
|
|
const result = await runTeamSourceSubprocess({ cwd: workDir, home })
|
|
assertEquals(result, { value: "ENG", source: "global-config" })
|
|
} finally {
|
|
await Deno.remove(workDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - project .env yields project-env source", async () => {
|
|
const projectDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.writeTextFile(`${projectDir}/.env`, "LINEAR_TEAM_ID=ENG\n")
|
|
const result = await runTeamSourceSubprocess({ cwd: projectDir, home })
|
|
assertEquals(result, { value: "ENG", source: "project-env" })
|
|
} finally {
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - process env wins over project .env and is classified env", async () => {
|
|
const projectDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.writeTextFile(`${projectDir}/.env`, "LINEAR_TEAM_ID=ENG\n")
|
|
const result = await runTeamSourceSubprocess({
|
|
cwd: projectDir,
|
|
home,
|
|
extraEnv: { LINEAR_TEAM_ID: "OPS" },
|
|
})
|
|
assertEquals(result, { value: "OPS", source: "env" })
|
|
} finally {
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - a .env directory is ignored, not fatal", async () => {
|
|
// Some monorepos have a `.env` directory. Loading it must not crash startup
|
|
// (previously threw IsADirectory); it is silently ignored like a missing file.
|
|
const projectDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.mkdir(`${projectDir}/.env`)
|
|
const result = await runTeamSourceSubprocess({ cwd: projectDir, home })
|
|
assertEquals(result, null)
|
|
} finally {
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - an unusable .env warns on stderr instead of passing silently", async () => {
|
|
// Staying silent would hide a file the user probably believes is configuring
|
|
// the CLI, so the compromise is: never fatal, but always say so on stderr.
|
|
const projectDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.mkdir(`${projectDir}/.env`)
|
|
const { result, stderr } = await runTeamSourceSubprocessRaw({
|
|
cwd: projectDir,
|
|
home,
|
|
})
|
|
assertEquals(result, null)
|
|
assertStringIncludes(stderr, "Warning: Ignoring")
|
|
assertStringIncludes(stderr, "it is a directory, not a file")
|
|
assertStringIncludes(stderr, "LINEAR_IGNORE_ENV_FILE=1")
|
|
} finally {
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - a .env directory falls back to the repository root .env", async () => {
|
|
const repoDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await initGitRepo(repoDir)
|
|
await Deno.writeTextFile(`${repoDir}/.env`, "LINEAR_TEAM_ID=ENG\n")
|
|
const packageDir = `${repoDir}/packages/app`
|
|
await Deno.mkdir(`${packageDir}/.env`, { recursive: true })
|
|
const { result, stderr } = await runTeamSourceSubprocessRaw({
|
|
cwd: packageDir,
|
|
home,
|
|
})
|
|
assertEquals(result, { value: "ENG", source: "project-env" })
|
|
assertStringIncludes(stderr, "Warning: Ignoring")
|
|
} finally {
|
|
await Deno.remove(repoDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - a repository root .env directory is ignored, not fatal", async () => {
|
|
const repoDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await initGitRepo(repoDir)
|
|
await Deno.mkdir(`${repoDir}/.env`)
|
|
const packageDir = `${repoDir}/packages/app`
|
|
await Deno.mkdir(packageDir, { recursive: true })
|
|
const { result, stderr } = await runTeamSourceSubprocessRaw({
|
|
cwd: packageDir,
|
|
home,
|
|
})
|
|
assertEquals(result, null)
|
|
assertStringIncludes(stderr, "Warning: Ignoring")
|
|
} finally {
|
|
await Deno.remove(repoDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - a shell-style .env with variable references does not hang startup", async () => {
|
|
// `export PATH=$PATH:/opt/bin` is ordinary in a .env meant to be sourced by a
|
|
// shell, and it used to spin @std/dotenv's expansion loop forever, hanging
|
|
// every command before it dispatched. A regression here fails on the
|
|
// subprocess timeout rather than returning a wrong value.
|
|
const projectDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.writeTextFile(
|
|
`${projectDir}/.env`,
|
|
[
|
|
"export PATH=$PATH:/opt/bin",
|
|
'if [[ -n "$CI" ]]; then',
|
|
" echo building",
|
|
"fi",
|
|
"LINEAR_TEAM_ID=ENG",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
const { result, stderr } = await runTeamSourceSubprocessRaw({
|
|
cwd: projectDir,
|
|
home,
|
|
})
|
|
assertEquals(result, { value: "ENG", source: "project-env" })
|
|
// PATH is not a key this CLI applies, so skipping it is not worth a warning.
|
|
assertEquals(stderr, "")
|
|
} finally {
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - a shell variable reference in a linear key is refused, not silently corrupted", async () => {
|
|
// The dotenv expander resolves an unset reference to the literal string
|
|
// "undefined"; refusing the value and saying so beats a corrupt team id.
|
|
const projectDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.writeTextFile(
|
|
`${projectDir}/.env`,
|
|
"LINEAR_TEAM_ID=$SOME_UNSET_VARIABLE\n",
|
|
)
|
|
const { result, stderr } = await runTeamSourceSubprocessRaw({
|
|
cwd: projectDir,
|
|
home,
|
|
})
|
|
assertEquals(result, null)
|
|
assertStringIncludes(stderr, "LINEAR_TEAM_ID")
|
|
assertStringIncludes(stderr, "references a shell variable")
|
|
} finally {
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - a dollar sign that cannot expand does not block the value", async () => {
|
|
// Only real `${NAME}` / `$NAME` references are refused. A `$` inside a
|
|
// trailing comment or a quoted value never reaches the expander, so treating
|
|
// it as one would drop a perfectly good setting.
|
|
const projectDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.writeTextFile(
|
|
`${projectDir}/.env`,
|
|
"LINEAR_TEAM_ID=ENG # owned by $TEAM\n",
|
|
)
|
|
const { result, stderr } = await runTeamSourceSubprocessRaw({
|
|
cwd: projectDir,
|
|
home,
|
|
})
|
|
assertEquals(result, { value: "ENG", source: "project-env" })
|
|
assertEquals(stderr, "")
|
|
} finally {
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - a quoted value containing a hash is kept intact", async () => {
|
|
const projectDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.writeTextFile(
|
|
`${projectDir}/.env`,
|
|
'LINEAR_TEAM_ID="ENG # 1"\n',
|
|
)
|
|
const { result, stderr } = await runTeamSourceSubprocessRaw({
|
|
cwd: projectDir,
|
|
home,
|
|
})
|
|
assertEquals(result, { value: "ENG # 1", source: "project-env" })
|
|
assertEquals(stderr, "")
|
|
} finally {
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - a quoted dollar reference is kept, since dotenv never expands it", async () => {
|
|
// @std/dotenv expands `$NAME` only in unquoted values, so a quoted one is
|
|
// neither a hang risk nor a corruption risk and must survive the filter.
|
|
const projectDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.writeTextFile(
|
|
`${projectDir}/.env`,
|
|
"LINEAR_TEAM_ID='$ENG'\n",
|
|
)
|
|
const { result, stderr } = await runTeamSourceSubprocessRaw({
|
|
cwd: projectDir,
|
|
home,
|
|
})
|
|
assertEquals(result, { value: "$ENG", source: "project-env" })
|
|
assertEquals(stderr, "")
|
|
} finally {
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - a skipped key the environment already sets is not reported", async () => {
|
|
// The .env value would have lost to the process environment regardless, so
|
|
// warning that we skipped it would point at a problem that changed nothing.
|
|
const projectDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.writeTextFile(
|
|
`${projectDir}/.env`,
|
|
"LINEAR_TEAM_ID=$SOME_UNSET_VARIABLE\n",
|
|
)
|
|
const { result, stderr } = await runTeamSourceSubprocessRaw({
|
|
cwd: projectDir,
|
|
home,
|
|
extraEnv: { LINEAR_TEAM_ID: "OPS" },
|
|
})
|
|
assertEquals(result, { value: "OPS", source: "env" })
|
|
assertEquals(stderr, "")
|
|
} finally {
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - LINEAR_IGNORE_ENV_FILE skips .env loading entirely", async () => {
|
|
const projectDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.mkdir(`${projectDir}/.env`)
|
|
const { result, stderr } = await runTeamSourceSubprocessRaw({
|
|
cwd: projectDir,
|
|
home,
|
|
extraEnv: { LINEAR_IGNORE_ENV_FILE: "1" },
|
|
})
|
|
assertEquals(result, null)
|
|
assertEquals(stderr, "")
|
|
} finally {
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
// getOption() silently drops a value that fails to parse, which would create a
|
|
// pull request quietly missing the configured template. resolvePrTemplate must
|
|
// error instead -- explicit input works or errors, it never falls back.
|
|
Deno.test("resolvePrTemplate - rejects an empty explicit value", () => {
|
|
assertThrows(
|
|
() => resolvePrTemplate(""),
|
|
ValidationError,
|
|
"Invalid pull request template",
|
|
)
|
|
})
|
|
|
|
Deno.test("resolvePrTemplate - false means --no-template and yields no path", () => {
|
|
assertEquals(resolvePrTemplate(false), undefined)
|
|
})
|
|
|
|
Deno.test("resolvePrTemplate - an explicit path is left relative to the cwd", () => {
|
|
// Only config-file values are rebased; a path typed on the command line means
|
|
// what it means in the shell the user typed it in.
|
|
assertEquals(resolvePrTemplate("docs/pr.md"), "docs/pr.md")
|
|
})
|
|
|
|
// A path written in a config file is relative to that file. The config loader
|
|
// finds <repo-root>/.linear.toml from any subdirectory, so resolving its value
|
|
// against the working directory instead would make a project-wide setting work
|
|
// at the repo root and fail everywhere below it.
|
|
Deno.test("optionBaseDir - a project config value resolves against the config file, not the cwd", async () => {
|
|
const repoDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.writeTextFile(
|
|
`${repoDir}/.linear.toml`,
|
|
'pr_template = ".github/pull_request_template.md"\n',
|
|
)
|
|
const nested = `${repoDir}/packages/app`
|
|
await Deno.mkdir(nested, { recursive: true })
|
|
// The repo-root config paths are only searched inside a git work tree.
|
|
await initGitRepo(repoDir)
|
|
|
|
const configUrl = new URL("../src/config.ts", import.meta.url)
|
|
const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url))
|
|
const homeDir = Deno.env.get("HOME")
|
|
const denoDir = Deno.env.get("DENO_DIR") ??
|
|
(homeDir == null ? undefined : `${homeDir}/.cache/deno`)
|
|
const command = new Deno.Command(Deno.execPath(), {
|
|
args: [
|
|
"eval",
|
|
`--config=${denoJsonPath}`,
|
|
`import { getOptionWithSource, optionBaseDir } from "${configUrl}";
|
|
const r = getOptionWithSource("pr_template");
|
|
console.log(JSON.stringify({ source: r?.source ?? null, base: optionBaseDir(r.source) ?? null }));`,
|
|
],
|
|
cwd: nested,
|
|
clearEnv: true,
|
|
env: {
|
|
HOME: home,
|
|
XDG_CONFIG_HOME: `${home}/.config`,
|
|
PATH: Deno.env.get("PATH") ?? "",
|
|
NO_COLOR: "1",
|
|
...(denoDir == null ? {} : { DENO_DIR: denoDir }),
|
|
},
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
})
|
|
const { stdout, stderr } = await command.output()
|
|
const out = new TextDecoder().decode(stdout).trim()
|
|
if (out === "") {
|
|
throw new Error(
|
|
`subprocess produced no output: ${new TextDecoder().decode(stderr)}`,
|
|
)
|
|
}
|
|
const result = JSON.parse(out)
|
|
|
|
assertEquals(result.source, "project-config")
|
|
// The base is the directory holding the config file, not the nested cwd.
|
|
assertEquals(result.base, repoDir)
|
|
} finally {
|
|
await Deno.remove(repoDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - invalid project value shadows valid global value", async () => {
|
|
// A present-but-invalid higher-precedence value must block fallback to a
|
|
// lower-precedence source, matching the pre-split spread-merge behavior.
|
|
const projectDir = await Deno.makeTempDir()
|
|
const home = await Deno.makeTempDir()
|
|
try {
|
|
await Deno.mkdir(`${home}/.config/linear`, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${home}/.config/linear/linear.toml`,
|
|
'team_id = "ENG"\n',
|
|
)
|
|
await Deno.writeTextFile(`${projectDir}/.linear.toml`, "team_id = 5\n")
|
|
const result = await runTeamSourceSubprocess({ cwd: projectDir, home })
|
|
assertEquals(result, null)
|
|
} finally {
|
|
await Deno.remove(projectDir, { recursive: true })
|
|
await Deno.remove(home, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("getOptionWithSource - cli value yields cli source", () => {
|
|
const result = getOptionWithSource("team_id", "eng")
|
|
assertEquals(result, { value: "eng", source: "cli" })
|
|
})
|