feat(ui): share the app bins' boot glue in @deepseek-ai/dsh-app-boot
The four near-twin helpers the two published bins carried — loadEnv, installFailLoud, assertEntriesLoaded, boot — live once in packages/ui/app-boot, parameterized by the bin's diagnostic prefix and injectable at their side-effect seams (warn sink, process slice), so every branch sits under the per-file 100% coverage gate: the unit suite drives boot() in-process against the real Loader (relative-specifier configs) through both the settled-tree path and the fiber-less-entry rejection, and exercises the ENOENT/unloadable .env split, the Error/non-Error/stackless fail-loud arms, and the disabled-entry exclusion. resolveConfigPath (snapshot-aware) becomes the single path resolver for both bins. Each bin.ts is now a thin self-executing composition plus its app-specific lifecycle (acp: replay env-skip + stdin-EOF dispose; stdio: nothing extra), exports nothing, and stays coverage-excluded; the built-bin smokes still prove both artifacts under plain node in the node_modules-shaped temp dir (now symlinking ui/app-boot), including the missing-config non-zero exit. Implements docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md (moved from proposed/ and amended); the extract-example-app-packages RFC's bin-ownership facts are amended in the same change.
This commit is contained in:
15
packages/ui/app-boot/README.md
Normal file
15
packages/ui/app-boot/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# `@deepseek-ai/dsh-app-boot`
|
||||
|
||||
Shared boot glue for the app bins ([`dsh-stdio-agent`](../stdio-agent/README.md), [`dsh-acp-agent`](../acp-agent/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts.
|
||||
|
||||
| Export | Role |
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
|
||||
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
|
||||
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
|
||||
| `boot(binName, absoluteConfigPath)` | Mount the Loader, include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context |
|
||||
|
||||
Two failure classes the guards close — both would otherwise exit 0 with a usable config typo reported only as a log line: `loader.await()` swallows init rejections (`Promise.allSettled`), surfaced instead by `installFailLoud`; a failed plugin IMPORT is only logged by the Loader, leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection.
|
||||
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`) resolve through the cordis Loader's internal module loader, active only under `node --expose-internals`; the bins' subprocess smokes exercise that path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
|
||||
34
packages/ui/app-boot/package.json
Normal file
34
packages/ui/app-boot/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-app-boot",
|
||||
"description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
152
packages/ui/app-boot/src/index.ts
Normal file
152
packages/ui/app-boot/src/index.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Shared boot glue for the app bins (`dsh-stdio-agent`, `dsh-acp-agent`): load
|
||||
* the gitignored `.env`, install the fail-loud Loader guards, resolve the
|
||||
* config path (snapshot-aware), and drive the cordis Loader against a leaf
|
||||
* `cordis.yml` until the whole tree has settled. Each bin stays a thin
|
||||
* self-executing `main()` over these helpers, parameterized by its diagnostic
|
||||
* prefix; the loader-failure lore lives here, once, under the per-file
|
||||
* coverage gate.
|
||||
*
|
||||
* Two failure classes the guards close, both of which would otherwise exit 0
|
||||
* with a usable config typo reported only as a log line:
|
||||
*
|
||||
* - `loader.await()` does NOT rethrow a load error (`EntryTree.await()` uses
|
||||
* `Promise.allSettled`, which swallows rejections). A plugin whose
|
||||
* `[Service.init]` throws surfaces as an unhandled rejection AFTER `boot()`
|
||||
* resolves — {@link installFailLoud} turns that into one labelled stderr
|
||||
* line and a guaranteed non-zero exit.
|
||||
* - A plugin module that fails to IMPORT is caught and only LOGGED by the
|
||||
* cordis Loader (`entry._init`), leaving the entry with no `fiber` and
|
||||
* producing no rejection — {@link assertEntriesLoaded} makes `boot()` reject
|
||||
* on any such entry instead of returning a half-empty context.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-app-boot
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
|
||||
/**
|
||||
* Resolve the config to boot, honoring snapshot REPLAY. Given the requested
|
||||
* path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in
|
||||
* the SAME directory (the keyless replay tree). Other modes — including no
|
||||
* snapshot mode at all — use the path as-is. Returns an absolute path resolved
|
||||
* from `cwd`.
|
||||
*/
|
||||
export function resolveConfigPath(
|
||||
configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(),
|
||||
): string {
|
||||
const absolute = resolve(cwd, configPath)
|
||||
if (snapshotMode !== 'replay') return absolute
|
||||
const dir = dirname(absolute)
|
||||
const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml')
|
||||
return resolve(dir, replayName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in
|
||||
* `dir` (Node native `process.loadEnvFile`). An absent file is fine — the
|
||||
* environment may already carry the variables; the leaf `cordis.yml` reads
|
||||
* them via the `!!js` tag. A present-but-unreadable `.env` is a real
|
||||
* misconfiguration: surface it via `warn` (one line, default stderr) rather
|
||||
* than silently running with the wrong environment.
|
||||
*/
|
||||
export function loadEnv(
|
||||
binName: string, dir: string = process.cwd(),
|
||||
warn: (line: string) => void = line => void process.stderr.write(line),
|
||||
): void {
|
||||
try {
|
||||
process.loadEnvFile(resolve(dir, '.env'))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
|
||||
warn(`${binName}: failed to load .env: ${String(error)}\n`)
|
||||
}
|
||||
// ENOENT (no .env) is fine — rely on the ambient environment.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of `process` {@link installFailLoud} needs — injectable so tests
|
||||
* exercise the handler without registering on (or exiting) the real process.
|
||||
*/
|
||||
export interface FailLoudProcess {
|
||||
on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
|
||||
off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
|
||||
stderr: { write(chunk: string): unknown }
|
||||
exit(code: number): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a load failure fail loud with a clear message on stderr. Covers the
|
||||
* failure path {@link assertEntriesLoaded} cannot: an include whose
|
||||
* `[Service.init]` throws (e.g. a config FILE that does not exist in a real
|
||||
* directory) surfaces as an unhandled promise rejection AFTER `boot()`
|
||||
* resolves. Node's default handler already exits non-zero on an unhandled
|
||||
* rejection; this replaces the noisy stack dump with a single labelled line on
|
||||
* STDERR (never stdout — for the ACP bin that channel carries JSON-RPC) and
|
||||
* guarantees `exit(1)`. Install before `boot()`. Returns the uninstaller
|
||||
* (tests use it; the bins run until exit and never do).
|
||||
*/
|
||||
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
|
||||
const handler = (err: unknown): void => {
|
||||
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
||||
proc.exit(1)
|
||||
}
|
||||
proc.on('unhandledRejection', handler)
|
||||
return () => void proc.off('unhandledRejection', handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* After the tree settles, assert every loader entry actually started. A
|
||||
* started entry has a `fiber`; an entry with `fiber === undefined` after the
|
||||
* tree settled never loaded (its module failed to import), so throw and let
|
||||
* `boot()` reject instead of returning a half-empty context. A `disabled`
|
||||
* entry is the one legitimate fiber-less state: `Entry.refresh()` deliberately
|
||||
* skips `init()` for it — a valid "plugin turned off" config, not a failed
|
||||
* import — so it is excluded.
|
||||
*/
|
||||
export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
if (failed.length > 0) {
|
||||
const names = failed.map(entry => entry.options.name).join(', ')
|
||||
throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the Loader against `absoluteConfigPath` and return the root context
|
||||
* once the whole tree has settled. The include is handed the config's ABSOLUTE
|
||||
* `file://` URL as its `path`, so resolution never depends on `ctx.baseUrl`
|
||||
* (an absolute URL ignores the base) and can never fall back to the cwd;
|
||||
* `baseUrl` is still pinned to the config's directory so the config's OWN
|
||||
* relative plugin/include paths resolve against it.
|
||||
*
|
||||
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns
|
||||
* once the include ENTRY is registered, but the include then loads its child
|
||||
* plugins asynchronously — without awaiting the tree, `boot()` would resolve
|
||||
* while the app's plugins are still mounting, and a CLI process with no
|
||||
* attached handles yet exits 0 silently. Failures surface two ways: an entry
|
||||
* whose module failed to import is caught here by {@link assertEntriesLoaded}
|
||||
* (this `boot()` rejects); an init that THROWS surfaces as an unhandled
|
||||
* rejection caught by {@link installFailLoud} (installed by the bin first).
|
||||
*
|
||||
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages)
|
||||
* are resolved by the cordis Loader's internal module loader, which is only
|
||||
* active under `node --expose-internals`; a consumer running a built bin must
|
||||
* pass that flag (or install the plugins where node hoists them). Relative
|
||||
* specifiers resolve against the config directory with no flag.
|
||||
*/
|
||||
export async function boot(binName: string, absoluteConfigPath: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.loader.create({
|
||||
name: '@cordisjs/plugin-include',
|
||||
config: { path: pathToFileURL(absoluteConfigPath).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx, binName)
|
||||
return ctx
|
||||
}
|
||||
178
packages/ui/app-boot/tests/app-boot.spec.ts
Normal file
178
packages/ui/app-boot/tests/app-boot.spec.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import {
|
||||
assertEntriesLoaded, boot, installFailLoud, loadEnv, resolveConfigPath,
|
||||
type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
|
||||
|
||||
describe('resolveConfigPath', () => {
|
||||
it('resolves relative to the given cwd outside replay mode', () => {
|
||||
expect(resolveConfigPath('./cordis.yml', undefined, `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.yml'))
|
||||
expect(resolveConfigPath('conf/app.yaml', 'record', `${sep}base`)).toBe(resolve(`${sep}base`, 'conf/app.yaml'))
|
||||
})
|
||||
|
||||
it('swaps a cordis.yml/.yaml basename for cordis.snapshot.yml in replay mode', () => {
|
||||
expect(resolveConfigPath('./cordis.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.snapshot.yml'))
|
||||
expect(resolveConfigPath('deep/cordis.yaml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'deep/cordis.snapshot.yml'))
|
||||
})
|
||||
|
||||
it('leaves a non-cordis basename alone in replay mode and defaults cwd to the process cwd', () => {
|
||||
expect(resolveConfigPath('custom.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'custom.yml'))
|
||||
expect(resolveConfigPath('./x.yml', undefined)).toBe(resolve(process.cwd(), 'x.yml'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadEnv', () => {
|
||||
it('loads variables from .env in the given dir', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n')
|
||||
const warn = vi.fn()
|
||||
loadEnv(NAME, dir, warn)
|
||||
expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded')
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
delete process.env['DSH_APP_BOOT_SPEC_VAR']
|
||||
})
|
||||
|
||||
it('stays silent when no .env exists (ambient environment wins)', () => {
|
||||
const warn = vi.fn()
|
||||
loadEnv(NAME, tmp(), warn)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns (labelled, single line) when .env exists but cannot be loaded', () => {
|
||||
const dir = tmp()
|
||||
mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file
|
||||
const warn = vi.fn()
|
||||
loadEnv(NAME, dir, warn)
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `))
|
||||
})
|
||||
|
||||
it('defaults dir to the process cwd and warn to a stderr write', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n')
|
||||
const previous = process.cwd()
|
||||
process.chdir(dir)
|
||||
try {
|
||||
loadEnv(NAME) // happy path: the default warn sink is never invoked
|
||||
} finally {
|
||||
process.chdir(previous)
|
||||
}
|
||||
expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes')
|
||||
delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS']
|
||||
// The default warn sink itself: point it at a broken .env with stderr
|
||||
// spied, so the arrow body runs without polluting the test output.
|
||||
const broken = tmp()
|
||||
mkdirSync(join(broken, '.env'))
|
||||
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
let written: string[]
|
||||
try {
|
||||
loadEnv(NAME, broken)
|
||||
written = write.mock.calls.map(call => String(call[0]))
|
||||
} finally {
|
||||
write.mockRestore()
|
||||
}
|
||||
expect(written).toHaveLength(1)
|
||||
expect(written[0]).toContain(`${NAME}: failed to load .env: `)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installFailLoud', () => {
|
||||
function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
|
||||
const handlers: Array<(err: unknown) => void> = []
|
||||
const written: string[] = []
|
||||
const exits: number[] = []
|
||||
return {
|
||||
handlers, written, exits,
|
||||
on: (_event, handler) => { handlers.push(handler) },
|
||||
off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) },
|
||||
stderr: { write: (chunk: string) => { written.push(chunk) } },
|
||||
exit: (code: number) => { exits.push(code) },
|
||||
}
|
||||
}
|
||||
|
||||
it('writes one labelled line with the stack and exits 1 on an Error rejection', () => {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc)
|
||||
const error = new Error('boom')
|
||||
proc.handlers[0]!(error)
|
||||
expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
|
||||
expect(proc.written[0]).toContain(error.stack)
|
||||
expect(proc.exits).toEqual([1])
|
||||
})
|
||||
|
||||
it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc)
|
||||
proc.handlers[0]!('plain failure')
|
||||
expect(proc.written[0]).toContain('plain failure')
|
||||
const stackless = new Error('no stack')
|
||||
delete (stackless as { stack?: string }).stack
|
||||
proc.handlers[0]!(stackless)
|
||||
expect(proc.written[1]).toContain('no stack')
|
||||
expect(proc.exits).toEqual([1, 1])
|
||||
})
|
||||
|
||||
it('returns an uninstaller that removes the handler (and defaults to the real process)', () => {
|
||||
const proc = fakeProc()
|
||||
const uninstall = installFailLoud(NAME, proc)
|
||||
expect(proc.handlers).toHaveLength(1)
|
||||
uninstall()
|
||||
expect(proc.handlers).toHaveLength(0)
|
||||
// Default-proc arm: install on the real process, then immediately uninstall
|
||||
// so the suite leaks no handler and can never exit the runner.
|
||||
const before = process.listenerCount('unhandledRejection')
|
||||
const uninstallReal = installFailLoud(NAME)
|
||||
expect(process.listenerCount('unhandledRejection')).toBe(before + 1)
|
||||
uninstallReal()
|
||||
expect(process.listenerCount('unhandledRejection')).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesLoaded', () => {
|
||||
const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
|
||||
({ loader: { entries: () => entries } }) as unknown as Context
|
||||
|
||||
it('passes when every enabled entry has a fiber', () => {
|
||||
expect(() => { assertEntriesLoaded(ctxWith([
|
||||
{ fiber: {}, options: { name: 'a' } },
|
||||
{ disabled: true, options: { name: 'off' } },
|
||||
]), NAME) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws naming every enabled fiber-less entry', () => {
|
||||
expect(() => { assertEntriesLoaded(ctxWith([
|
||||
{ fiber: {}, options: { name: 'ok' } },
|
||||
{ options: { name: 'broken-a' } },
|
||||
{ options: { name: 'broken-b' } },
|
||||
]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot', () => {
|
||||
it('boots a leaf config through the real Loader and settles the tree', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
const entries = [...ctx.loader.entries()]
|
||||
expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
|
||||
})
|
||||
})
|
||||
21
packages/ui/app-boot/tsconfig.json
Normal file
21
packages/ui/app-boot/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/include"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user