-
-
Notifications
You must be signed in to change notification settings - Fork 15
feat(devx): isolated per-worktree Obsidian E2E vault wrapper #188
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
Merged
Merged
Changes from 7 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
5328994
feat(devx): isolated per-worktree Obsidian E2E vault wrapper
chhoumann 8dad6e0
chore(devx): type-check scripts/ test files via tsconfig
chhoumann 9a4052e
fix(devx): clean --print-env stdout and avoid readiness-probe double-…
chhoumann af323e7
fix(devx): anchor default vault root to worktree; reuse running instance
chhoumann 535d44a
fix(devx): reload PodNotes when reusing an instance so rebuilds take …
chhoumann d1f7e65
fix(devx): gate the readiness wait on the CLI socket to avoid a secon…
chhoumann c45008c
fix(devx): reload a reused instance before the readiness check
chhoumann e42465c
fix(devx): reload on reuse in start main(); build before the exported…
chhoumann 6090474
fix(devx): don't self-link Keychains when HOME is the private profile
chhoumann 411ce4c
fix(devx): export OBSIDIAN_BIN from --print-env for a custom CLI binary
chhoumann File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| #!/usr/bin/env node | ||
| import { spawn } from "node:child_process"; | ||
| import process from "node:process"; | ||
| import { provisionVault } from "./provision-obsidian-e2e-vault.mjs"; | ||
| import { | ||
| isInstanceReady, | ||
| launchObsidianInstance, | ||
| parseArgs as parseInstanceArgs, | ||
| prepareObsidianProfile, | ||
| reloadPodNotes, | ||
| resolveInstanceOptions, | ||
| trustVaultAndVerifyPodNotes, | ||
| waitForInstanceReady, | ||
| } from "./start-obsidian-e2e-instance.mjs"; | ||
|
|
||
| const VALUE_OPTIONS = new Set([ | ||
| "--vault", | ||
| "--root", | ||
| "--worktree", | ||
| "--data", | ||
| "--profile-root", | ||
| "--obsidian-app", | ||
| "--obsidian-bin", | ||
| ]); | ||
| const BOOLEAN_OPTIONS = new Set(["--force"]); | ||
| const DEFAULT_COMMAND = ["eval", "code=app.vault.getName()"]; | ||
|
|
||
| function printUsage() { | ||
| console.log(`Usage: node scripts/obsidian-e2e-cli.mjs [instance options] [--] <obsidian command...> | ||
|
|
||
| Examples: | ||
| npm run obsidian:e2e -- eval code=app.vault.getName() | ||
| npm run obsidian:e2e -- dev:errors | ||
| npm run obsidian:e2e -- --vault podnotes-my-worktree eval code='app.plugins.plugins.podnotes?.manifest?.version' | ||
|
|
||
| Instance options: | ||
| --vault <name> Vault/profile name. Defaults to podnotes-<worktree>. | ||
| --root <path> Directory that contains provisioned vaults. Defaults to .obsidian-e2e-vaults. | ||
| --worktree <path> PodNotes worktree to link plugin files from. Defaults to cwd. | ||
| --data <path> Optional PodNotes data.json seed to copy on first provision. | ||
| --profile-root <path> Directory for per-vault Obsidian HOME profiles. Defaults to /tmp/podnotes-obsidian-e2e. | ||
| --obsidian-app <name> Obsidian app name for macOS open. Defaults to Obsidian. | ||
| --obsidian-bin <path> Obsidian CLI executable. Defaults to obsidian. | ||
| --force Recreate plugin symlinks if they already exist. | ||
| --help Show this help. | ||
| `); | ||
| } | ||
|
|
||
| export function parseArgs(argv) { | ||
| const instanceArgs = []; | ||
| const commandArgs = []; | ||
|
|
||
| for (let index = 0; index < argv.length; index += 1) { | ||
| const arg = argv[index]; | ||
| if (arg === "--") { | ||
| const next = argv[index + 1]; | ||
| if ( | ||
| index === 0 && | ||
| (next === "--help" || | ||
| BOOLEAN_OPTIONS.has(next) || | ||
| VALUE_OPTIONS.has(next)) | ||
| ) { | ||
| continue; | ||
| } | ||
| commandArgs.push(...argv.slice(index + 1)); | ||
| break; | ||
| } | ||
| if (arg === "--help") { | ||
| return { help: true, instanceArgs, commandArgs }; | ||
| } | ||
| if (BOOLEAN_OPTIONS.has(arg)) { | ||
| instanceArgs.push(arg); | ||
| continue; | ||
| } | ||
| if (VALUE_OPTIONS.has(arg)) { | ||
| const value = argv[index + 1]; | ||
| if (!value || value.startsWith("--")) { | ||
| throw new Error(`${arg} requires a value.`); | ||
| } | ||
| instanceArgs.push(arg, value); | ||
| index += 1; | ||
| continue; | ||
| } | ||
|
|
||
| commandArgs.push(...argv.slice(index)); | ||
| break; | ||
| } | ||
|
|
||
| return { | ||
| help: false, | ||
| instanceArgs, | ||
| commandArgs: commandArgs.length > 0 ? commandArgs : [...DEFAULT_COMMAND], | ||
| }; | ||
| } | ||
|
|
||
| export function obsidianEnv(options) { | ||
| return { | ||
| ...process.env, | ||
| HOME: options.obsidianHome, | ||
| }; | ||
| } | ||
|
|
||
| export function obsidianCommandArgs(options, commandArgs) { | ||
| return [`vault=${options.vaultName}`, ...commandArgs]; | ||
| } | ||
|
|
||
| export async function ensureObsidianInstance(options) { | ||
| const provisionResult = await provisionVault(options); | ||
| const profileResult = await prepareObsidianProfile(options); | ||
| options.userDataPath = profileResult.userDataPath; | ||
|
|
||
| const reused = await isInstanceReady(options); | ||
| if (reused) { | ||
| // A reused instance still holds the bundle it loaded earlier — possibly a | ||
| // broken pre-rebuild one. Reload BEFORE verifying so the rebuilt main.js is | ||
| // loaded first; otherwise a failed old bundle would make the readiness | ||
| // check below time out before the reload ever runs. | ||
| await reloadPodNotes(options); | ||
| } else { | ||
| // A freshly launched instance loads the current bundle on its own. | ||
| await launchObsidianInstance(options); | ||
| await waitForInstanceReady(options); | ||
| } | ||
|
|
||
| await trustVaultAndVerifyPodNotes(options); | ||
|
chhoumann marked this conversation as resolved.
|
||
|
|
||
| return { | ||
| ...provisionResult, | ||
| ...profileResult, | ||
| obsidianHome: options.obsidianHome, | ||
| }; | ||
| } | ||
|
|
||
| function spawnObsidian(options, commandArgs) { | ||
| return new Promise((resolve) => { | ||
| const child = spawn( | ||
| options.obsidianBin, | ||
| obsidianCommandArgs(options, commandArgs), | ||
| { | ||
| env: obsidianEnv(options), | ||
| stdio: "inherit", | ||
| }, | ||
| ); | ||
| child.on("close", (code, signal) => { | ||
| if (signal) { | ||
| process.kill(process.pid, signal); | ||
| return; | ||
| } | ||
| resolve(code ?? 1); | ||
| }); | ||
| child.on("error", (error) => { | ||
| console.error(error instanceof Error ? error.message : error); | ||
| resolve(1); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| async function main() { | ||
| const parsed = parseArgs(process.argv.slice(2)); | ||
| if (parsed.help) { | ||
| printUsage(); | ||
| return; | ||
| } | ||
|
|
||
| const options = resolveInstanceOptions( | ||
| parseInstanceArgs(parsed.instanceArgs), | ||
| ); | ||
| await ensureObsidianInstance(options); | ||
| process.exitCode = await spawnObsidian(options, parsed.commandArgs); | ||
| } | ||
|
|
||
| if (import.meta.url === `file://${process.argv[1]}`) { | ||
| main().catch((error) => { | ||
| console.error(error instanceof Error ? error.message : error); | ||
| process.exitCode = 1; | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { | ||
| obsidianCommandArgs, | ||
| obsidianEnv, | ||
| parseArgs, | ||
| } from "./obsidian-e2e-cli.mjs"; | ||
|
|
||
| describe("obsidian-e2e-cli", () => { | ||
| it("defaults to an eval of the vault name when no Obsidian command is provided", () => { | ||
| const parsed = parseArgs([]); | ||
|
|
||
| expect(parsed.instanceArgs).toEqual([]); | ||
| expect(parsed.commandArgs).toEqual(["eval", "code=app.vault.getName()"]); | ||
| }); | ||
|
|
||
| it("splits instance options from the Obsidian command", () => { | ||
| const parsed = parseArgs([ | ||
| "--vault", | ||
| "podnotes-worktree-a", | ||
| "--profile-root", | ||
| "profiles", | ||
| "dev:errors", | ||
| ]); | ||
|
|
||
| expect(parsed.instanceArgs).toEqual([ | ||
| "--vault", | ||
| "podnotes-worktree-a", | ||
| "--profile-root", | ||
| "profiles", | ||
| ]); | ||
| expect(parsed.commandArgs).toEqual(["dev:errors"]); | ||
| }); | ||
|
|
||
| it("uses -- to pass option-like Obsidian command arguments", () => { | ||
| const parsed = parseArgs([ | ||
| "--vault", | ||
| "podnotes-worktree-a", | ||
| "--", | ||
| "eval", | ||
| "--some-obsidian-flag", | ||
| ]); | ||
|
|
||
| expect(parsed.instanceArgs).toEqual(["--vault", "podnotes-worktree-a"]); | ||
| expect(parsed.commandArgs).toEqual(["eval", "--some-obsidian-flag"]); | ||
| }); | ||
|
|
||
| it("accepts the leading separator produced by npm run before wrapper options", () => { | ||
| const parsed = parseArgs([ | ||
| "--", | ||
| "--vault", | ||
| "podnotes-worktree-a", | ||
| "eval", | ||
| "code=app.vault.getName()", | ||
| ]); | ||
|
|
||
| expect(parsed.instanceArgs).toEqual(["--vault", "podnotes-worktree-a"]); | ||
| expect(parsed.commandArgs).toEqual(["eval", "code=app.vault.getName()"]); | ||
| }); | ||
|
|
||
| it("prefixes commands with the resolved isolated vault", () => { | ||
| expect( | ||
| obsidianCommandArgs({ vaultName: "podnotes-worktree-a" }, [ | ||
| "eval", | ||
| "code=app.vault.getName()", | ||
| ]), | ||
| ).toEqual([ | ||
| "vault=podnotes-worktree-a", | ||
| "eval", | ||
| "code=app.vault.getName()", | ||
| ]); | ||
| }); | ||
|
|
||
| it("runs Obsidian CLI commands with the isolated HOME", () => { | ||
| expect(obsidianEnv({ obsidianHome: "/tmp/podnotes/home" })).toMatchObject({ | ||
| HOME: "/tmp/podnotes/home", | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.