chore: use project-local dependency tooling

This commit is contained in:
yinsh 2026-07-12 10:46:44 +08:00
parent 0b979946b8
commit b0b2305113
13 changed files with 14 additions and 387 deletions

2
.gitignore vendored
View file

@ -23,6 +23,7 @@ data.json
# ── GSD baseline (auto-generated) ──
.gsd-id
.gsd/
.bg-shell/
Thumbs.db
*.swp
@ -35,7 +36,6 @@ Thumbs.db
.env.*
!.env.example
node_modules/
.tsconfig.external-node-modules.json
.next/
dist/
build/

4
.gsd/.gitignore vendored
View file

@ -1,4 +0,0 @@
# Exclude all GSD data files from version control
*
# But keep this file itself tracked
!.gitignore

View file

@ -1,18 +0,0 @@
{
"mcpServers": {
"gsd-workflow": {
"command": "C:\\rd\\nodejs\\node.exe",
"args": [
"C:\\rd\\nodejs\\node_global\\node_modules\\gsd-pi\\packages\\mcp-server\\dist\\cli.js"
],
"cwd": "C:\\OneDrive\\src\\obsidian-download-image",
"env": {
"GSD_CLI_PATH": "C:\\rd\\nodejs\\node_global\\node_modules\\gsd-pi\\dist\\loader.js",
"GSD_WORKFLOW_EXECUTORS_MODULE": "C:\\Users\\yinsh\\.gsd\\agent\\extensions\\gsd\\tools\\workflow-tool-executors.js",
"GSD_WORKFLOW_WRITE_GATE_MODULE": "C:\\Users\\yinsh\\.gsd\\agent\\extensions\\gsd\\bootstrap\\write-gate.js",
"GSD_PERSIST_WRITE_GATE_STATE": "1",
"GSD_WORKFLOW_PROJECT_ROOT": "C:\\OneDrive\\src\\obsidian-download-image"
}
}
}
}

View file

@ -4,7 +4,7 @@
- Keep source code in `src/`; keep `src/main.ts` focused on plugin lifecycle and command registration.
- Do not commit generated artifacts such as `node_modules/` or `main.js` unless explicitly requested for a release workflow.
- Use the project npm scripts, especially `npm run deps:install` instead of bare `npm install`.
- If `EXTERNAL_NODE_MODULES` is set, dependencies are managed through the external dependency wrapper described in `README.md` and `EXTERNAL-NODE-MODULES-GUIDE.md`.
- Keep dependencies in the project-local `node_modules/` directory; do not redirect them through environment variables or external dependency wrappers.
- Register Obsidian events, DOM events, and intervals through plugin cleanup helpers so unload remains safe.
- Preserve privacy: avoid unnecessary network requests, telemetry, remote code execution, or access outside the vault.
- Keep `.planning/` project artifacts under version control; keep transient GSD runtime state in `.gsd/` ignored.

View file

@ -1,137 +0,0 @@
# External node_modules guide
This guide documents the reusable external `node_modules` setup used by this project. It is intended for maintainers who want to keep dependency folders out of synced project directories such as OneDrive, while still allowing the project to work as a normal local `node_modules` project by default.
## Behavior
The setup has two modes:
| Mode | How it is selected | Where dependencies live |
|---|---|---|
| Local mode | `EXTERNAL_NODE_MODULES` is not set | `./node_modules` in the project directory |
| External mode | `EXTERNAL_NODE_MODULES` points to a dependency directory | The directory named by `EXTERNAL_NODE_MODULES` |
Use the same install command in both modes:
```powershell
npm run deps:install
```
- In local mode, this behaves like `npm install`.
- In external mode, this installs with `npm --prefix <external-root>` and resolves tools from the external dependency directory.
Do not create a symlink or junction from the project directory back to the external dependency directory. The scripts resolve tools explicitly.
## PowerShell setup
Add a reusable `setenv` function to your PowerShell Profile, then run it from the project root before npm commands:
```powershell
setenv
```
A minimal Profile function is:
```powershell
function setenv {
$ProjectRoot = Get-Location
$ProjectName = Split-Path -Leaf $ProjectRoot
$ExternalRoot = "C:/local_data/$ProjectName"
$ExternalNodeModules = "$ExternalRoot/node_modules"
$ExternalNodeBin = "$ExternalNodeModules/.bin"
New-Item -ItemType Directory -Force -Path $ExternalRoot | Out-Null
$ManifestFiles = @('package.json', 'package-lock.json', '.npmrc')
foreach ($FileName in $ManifestFiles) {
$Source = Join-Path $ProjectRoot $FileName
if (Test-Path $Source) {
Copy-Item -Path $Source -Destination (Join-Path $ExternalRoot $FileName) -Force
}
}
$env:EXTERNAL_NODE_MODULES = $ExternalNodeModules
$env:NODE_PATH = $ExternalNodeModules
$ExistingPathEntries = $env:PATH -split ';' | Where-Object {
$_ -and ($_.TrimEnd('\/') -ine $ExternalNodeBin.TrimEnd('\/'))
}
$env:PATH = (@($ExternalNodeBin) + $ExistingPathEntries) -join ';'
}
```
The function sets:
```powershell
$env:EXTERNAL_NODE_MODULES = "C:/local_data/<project-folder>/node_modules"
$env:NODE_PATH = $env:EXTERNAL_NODE_MODULES
$env:PATH = "<external-node_modules>/.bin;..."
```
For this repository, the default external dependency path is:
```text
C:/local_data/obsidian-download-image/node_modules
```
## Reusing in another project
Copy these files into another project:
```text
scripts/with-external-node-modules.mjs
scripts/external-npm.mjs
scripts/run-tool.mjs
scripts/eslint-loader.mjs
```
Then add the `setenv` function to your PowerShell Profile once and reuse it across projects.
## package.json scripts
Use a wrapper for dependency installation and tool execution:
```json
{
"scripts": {
"deps:install": "node scripts/external-npm.mjs install",
"deps:clean": "node scripts/external-npm.mjs exec -- rimraf node_modules",
"build": "node scripts/run-tool.mjs tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"lint": "node --import ./scripts/eslint-loader.mjs scripts/run-tool.mjs eslint .",
"test": "node scripts/run-tool.mjs vitest run"
}
}
```
## esbuild
Load esbuild through the wrapper so the build works in both modes:
```js
import {
EXTERNAL_MODE,
EXTERNAL_NODE_MODULES,
createToolEnv,
ensureToolNodeModules,
requireTool,
} from './scripts/with-external-node-modules.mjs';
ensureToolNodeModules();
process.env = createToolEnv();
const esbuild = requireTool('esbuild');
```
When bundling, pass external node paths only in external mode:
```js
...(EXTERNAL_MODE ? { nodePaths: [EXTERNAL_NODE_MODULES] } : {}),
```
## TypeScript, ESLint, and Vitest
- `scripts/run-tool.mjs` resolves `tsc`, `eslint`, and `vitest` from the configured dependency directory.
- In external mode, it writes `.tsconfig.external-node-modules.json` so TypeScript and ESLint can resolve external packages and types.
- `vitest.config.ts` adds `deps.moduleDirectories` in external mode so test dependencies resolve from the external directory.
`.tsconfig.external-node-modules.json` is generated and should stay ignored by Git.

View file

@ -76,23 +76,14 @@ This repository is also usable for local plugin development. Build the plugin, t
### Install dependencies
Use the project dependency wrapper instead of bare `npm install`:
Install dependencies into this project's local `node_modules` directory:
```bash
npm run deps:install
```
By default, this behaves like `npm install` and creates `./node_modules` in the project directory.
If you want to keep dependencies outside this OneDrive-synced project directory, run the PowerShell Profile function `setenv` from the project root first so `EXTERNAL_NODE_MODULES` points at `C:/local_data/obsidian-download-image/node_modules`, then run:
```bash
npm run deps:install
```
Do not use bare `npm install` for the external dependency workflow. `EXTERNAL_NODE_MODULES` is a project-specific variable, not an npm setting, so npm will ignore it unless you go through `npm run deps:install`. A bare `npm install` will still create `./node_modules` in the current project directory.
See [External node_modules guide](EXTERNAL-NODE-MODULES-GUIDE.md) for details.
This script runs `npm install` in the project root. Dependencies are always kept in
`./node_modules`; no environment setup or external dependency directory is used.
### Run the development build

View file

@ -1,18 +1,6 @@
import { builtinModules, createRequire } from "node:module";
import { builtinModules } from "node:module";
import esbuild from "esbuild";
import process from "process";
import {
EXTERNAL_MODE,
EXTERNAL_NODE_MODULES,
createToolEnv,
ensureToolNodeModules,
requireTool,
} from "./scripts/with-external-node-modules.mjs";
ensureToolNodeModules();
process.env = createToolEnv();
const require = createRequire(import.meta.url);
const esbuild = requireTool("esbuild");
const banner =
`/*
@ -29,7 +17,6 @@ const context = await esbuild.context({
},
entryPoints: ["src/main.ts"],
bundle: true,
...(EXTERNAL_MODE ? { nodePaths: [EXTERNAL_NODE_MODULES] } : {}),
external: [
"obsidian",
"electron",

View file

@ -5,13 +5,12 @@
"main": "main.js",
"type": "module",
"scripts": {
"deps:install": "node scripts/external-npm.mjs install",
"deps:clean": "node scripts/external-npm.mjs exec -- rimraf node_modules",
"deps:install": "npm install",
"dev": "node esbuild.config.mjs",
"build": "node scripts/run-tool.mjs tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "node --import ./scripts/eslint-loader.mjs scripts/run-tool.mjs eslint .",
"test": "node scripts/run-tool.mjs vitest run",
"lint": "eslint .",
"test": "vitest run",
"verify-release": "node scripts/verify-release.mjs"
},
"keywords": [],

View file

@ -1,8 +0,0 @@
import { dirname, join } from 'node:path';
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
import { resolveTool } from './with-external-node-modules.mjs';
const jitiRegister = join(dirname(resolveTool('jiti/package.json')), 'lib/jiti-register.mjs');
register(pathToFileURL(jitiRegister), pathToFileURL(`${process.cwd()}/`));

View file

@ -1,46 +0,0 @@
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
import { basename, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { EXTERNAL_MODE, EXTERNAL_ROOT } from './with-external-node-modules.mjs';
const args = process.argv.slice(2);
const command = args[0] ?? 'install';
const forwarded = args.slice(1);
function syncPackageManifests() {
if (!EXTERNAL_MODE || !['ci', 'install', 'i'].includes(command)) {
return;
}
mkdirSync(EXTERNAL_ROOT, { recursive: true });
for (const manifestPath of ['package.json', 'package-lock.json']) {
if (existsSync(manifestPath)) {
copyFileSync(manifestPath, join(EXTERNAL_ROOT, basename(manifestPath)));
}
}
}
syncPackageManifests();
const npmArgs = EXTERNAL_MODE
? [command, '--prefix', EXTERNAL_ROOT, ...forwarded]
: [command, ...forwarded];
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const result = spawnSync(npmCommand, npmArgs, {
stdio: 'inherit',
cwd: process.cwd(),
shell: process.platform === 'win32',
env: EXTERNAL_MODE ? {
...process.env,
npm_config_prefix: EXTERNAL_ROOT,
} : process.env,
});
if (result.error) {
console.error(result.error);
process.exit(1);
}
process.exit(result.status ?? 1);

View file

@ -1,69 +0,0 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import {
EXTERNAL_MODE,
EXTERNAL_NODE_MODULES,
createToolEnv,
resolveTool,
} from './with-external-node-modules.mjs';
const command = process.argv[2];
const rawArgs = process.argv.slice(3);
const tools = {
tsc: () => resolveTool('typescript/bin/tsc'),
eslint: () => join(dirname(resolveTool('eslint/package.json')), 'bin/eslint.js'),
vitest: () => join(dirname(resolveTool('vitest/package.json')), 'vitest.mjs'),
};
if (!command || !(command in tools)) {
console.error(`Usage: node scripts/run-tool.mjs ${Object.keys(tools).join('|')} [args...]`);
process.exit(1);
}
function createExternalTsConfig() {
const baseConfig = JSON.parse(readFileSync('tsconfig.json', 'utf8'));
const compilerOptions = baseConfig.compilerOptions ?? {};
const existingPaths = compilerOptions.paths?.['*'] ?? ['src/*', 'node_modules/*'];
baseConfig.compilerOptions = {
...compilerOptions,
paths: {
...(compilerOptions.paths ?? {}),
'*': [
...existingPaths,
`${EXTERNAL_NODE_MODULES.replaceAll('\\', '/')}/*`,
],
},
typeRoots: [
'node_modules/@types',
`${EXTERNAL_NODE_MODULES.replaceAll('\\', '/')}/@types`,
],
};
const externalTsConfigPath = '.tsconfig.external-node-modules.json';
writeFileSync(externalTsConfigPath, `${JSON.stringify(baseConfig, null, '\t')}\n`);
return externalTsConfigPath;
}
function argsForCommand() {
if (EXTERNAL_MODE && (command === 'tsc' || command === 'eslint')) {
const externalTsConfig = createExternalTsConfig();
if (command === 'tsc' && !rawArgs.includes('--project') && !rawArgs.includes('-p')) {
return ['--project', externalTsConfig, ...rawArgs];
}
}
return rawArgs;
}
const entry = tools[command]();
const result = spawnSync(process.execPath, [entry, ...argsForCommand()], {
stdio: 'inherit',
cwd: process.cwd(),
env: createToolEnv(),
});
process.exit(result.status ?? 1);

View file

@ -1,64 +0,0 @@
import { createRequire } from 'node:module';
import { existsSync } from 'node:fs';
import { delimiter, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const EXTERNAL_NODE_MODULES_ENV = 'EXTERNAL_NODE_MODULES';
export const EXTERNAL_NODE_MODULES = process.env[EXTERNAL_NODE_MODULES_ENV]?.trim() || '';
export const EXTERNAL_MODE = EXTERNAL_NODE_MODULES.length > 0;
export const EXTERNAL_ROOT = EXTERNAL_MODE ? dirname(EXTERNAL_NODE_MODULES) : process.cwd();
export const EXTERNAL_BIN = EXTERNAL_MODE ? join(EXTERNAL_NODE_MODULES, '.bin') : join(process.cwd(), 'node_modules/.bin');
const localRequire = createRequire(import.meta.url);
const externalRequire = EXTERNAL_MODE ? createRequire(join(EXTERNAL_ROOT, 'package.json')) : localRequire;
export function requireTool(specifier) {
return externalRequire(specifier);
}
export function resolveTool(specifier) {
return externalRequire.resolve(specifier);
}
export function resolveOptionalTool(specifier) {
try {
return resolveTool(specifier);
} catch (error) {
if (EXTERNAL_MODE) {
throw error;
}
return localRequire.resolve(specifier);
}
}
export function ensureToolNodeModules() {
if (EXTERNAL_MODE && !existsSync(EXTERNAL_NODE_MODULES)) {
throw new Error(
`${EXTERNAL_NODE_MODULES_ENV} points to ${EXTERNAL_NODE_MODULES}, but that directory does not exist. Run "npm run deps:install" after running the PowerShell Profile function setenv, or unset ${EXTERNAL_NODE_MODULES_ENV} to use local node_modules.`,
);
}
}
export function createToolEnv(extra = {}) {
ensureToolNodeModules();
if (!EXTERNAL_MODE) {
return {
...process.env,
...extra,
};
}
return {
...process.env,
...extra,
npm_config_prefix: EXTERNAL_ROOT,
NODE_PATH: [EXTERNAL_NODE_MODULES, process.env.NODE_PATH].filter(Boolean).join(delimiter),
PATH: [EXTERNAL_BIN, process.env.PATH].filter(Boolean).join(delimiter),
};
}
export function localScriptPath(relativePath) {
return fileURLToPath(new URL(relativePath, import.meta.url));
}

View file

@ -1,8 +1,7 @@
import path from 'path';
import { EXTERNAL_MODE, EXTERNAL_NODE_MODULES, EXTERNAL_ROOT } from './scripts/with-external-node-modules.mjs';
import { defineConfig } from 'vitest/config';
export default {
...(EXTERNAL_MODE ? { cacheDir: `${EXTERNAL_ROOT}/.vite-cache` } : {}),
export default defineConfig({
resolve: {
alias: {
obsidian: path.resolve(__dirname, 'tests/__mocks__/obsidian.ts'),
@ -13,8 +12,5 @@ export default {
test: {
environment: 'node',
include: ['tests/**/*.test.ts'],
deps: {
...(EXTERNAL_MODE ? { moduleDirectories: [EXTERNAL_NODE_MODULES] } : {}),
},
},
};
});