chore(skills): configuration des compétences Impeccable pour les agents
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m19s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m19s
This commit is contained in:
503
.github/skills/impeccable/scripts/live-inject.mjs
vendored
Normal file
503
.github/skills/impeccable/scripts/live-inject.mjs
vendored
Normal file
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* CLI helper: insert/remove the live variant mode script tag in the project's
|
||||
* main HTML entry point.
|
||||
*
|
||||
* On first live run, the agent generates `.impeccable/live/config.json`
|
||||
* with the project's insertion target (framework-specific). On
|
||||
* every subsequent run, this script handles insert/remove deterministically
|
||||
* with zero LLM involvement.
|
||||
*
|
||||
* Framework knowledge lives in `live/frameworks/` — detection order, adapters,
|
||||
* the generic tag strategy, and the per-extension authoring traits live-wrap
|
||||
* reads. This file is the CLI around it: resolve config, resolve the
|
||||
* framework, heal orphaned artifacts, apply or remove, record the journal.
|
||||
*
|
||||
* Usage:
|
||||
* node live-inject.mjs --port PORT [--token TOKEN] # Insert the live script tag
|
||||
* node live-inject.mjs --remove # Remove the live script tag
|
||||
* node live-inject.mjs --check # Check whether live config exists
|
||||
*
|
||||
* When --token is supplied, it is appended to the /live.js src as `?token=...`
|
||||
* so the server's token-gated /live.js handler will serve the bundle. Omitting
|
||||
* the token yields a bare `/live.js` src (legacy behavior; the server returns
|
||||
* 401 for it under the current gate).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
|
||||
import {
|
||||
describeInjectArtifacts,
|
||||
frameworkIgnorePatterns,
|
||||
resolveFramework,
|
||||
resolveSourceTraits,
|
||||
} from './live/frameworks/index.mjs';
|
||||
import {
|
||||
clearInjectJournal,
|
||||
healInjectJournal,
|
||||
recordInjection,
|
||||
} from './live/frameworks/journal.mjs';
|
||||
import {
|
||||
buildTagBlock,
|
||||
insertTag,
|
||||
patchCspMeta,
|
||||
removeTag,
|
||||
revertCspMeta,
|
||||
} from './live/frameworks/tag-strategy.mjs';
|
||||
import { buildLiveScriptSrc } from './live/frameworks/script-src.mjs';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Resolved lazily so the enterLiveRoot() chdir in the CLI guard below takes
|
||||
// effect first; module scope runs before the guard.
|
||||
let CONFIG_PATH_CACHED = null;
|
||||
function CONFIG_PATH_GET() {
|
||||
if (!CONFIG_PATH_CACHED) {
|
||||
CONFIG_PATH_CACHED = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
}
|
||||
return CONFIG_PATH_CACHED;
|
||||
}
|
||||
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
|
||||
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
|
||||
|
||||
export const LIVE_IGNORE_PATTERNS = Object.freeze([
|
||||
'.impeccable/hook.cache.json',
|
||||
'.impeccable/hook.pending.json',
|
||||
'.impeccable/config.local.json',
|
||||
'.impeccable/live/server.json',
|
||||
'.impeccable/live/roots.json',
|
||||
'.impeccable/live/app-root.json',
|
||||
'.impeccable/live/inject-journal.json',
|
||||
'.impeccable/live/sessions/',
|
||||
'.impeccable/live/previews/',
|
||||
'.impeccable/live/annotations/',
|
||||
'.impeccable/live/artifacts/',
|
||||
'.impeccable/live/accept-receipts/',
|
||||
'.impeccable/live/locks/',
|
||||
'.impeccable/live/cache/',
|
||||
'.impeccable/live/manual-edit-apply-transaction.json',
|
||||
'.impeccable/live/manual-edit-events.jsonl',
|
||||
'.impeccable/live/manual-edit-evidence/',
|
||||
'.impeccable/live/pending-manual-edits.json',
|
||||
'.impeccable/live/deferred-svelte-component-accepts.json',
|
||||
'.impeccable-live.json',
|
||||
'.impeccable-live/',
|
||||
'app/.impeccable-live/',
|
||||
'src/.impeccable-live/',
|
||||
'node_modules/.impeccable-live/',
|
||||
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
|
||||
'src/lib/impeccable/__runtime.js',
|
||||
'src/lib/impeccable/[0-9a-f]*/',
|
||||
'plugins/impeccable-live.client.ts',
|
||||
'app/plugins/impeccable-live.client.ts',
|
||||
'src/plugins/impeccable-live.client.ts',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
* matching them would silently inject tracking scripts into third-party
|
||||
* code. The user cannot turn these off via config — they are the floor.
|
||||
*/
|
||||
const HARD_EXCLUDES = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
];
|
||||
|
||||
export async function injectCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live-inject.mjs [options]
|
||||
|
||||
Insert or remove the live mode script tag in the project's HTML entry point.
|
||||
Reads configuration from .impeccable/live/config.json.
|
||||
|
||||
Modes:
|
||||
--port PORT Insert script tag pointing at http://localhost:PORT/live.js
|
||||
--remove Remove the script tag (if present)
|
||||
--check Print whether .impeccable/live/config.json exists and its content
|
||||
|
||||
Output (JSON):
|
||||
{ ok, file, inserted|removed, config? }`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args.includes('--check')) {
|
||||
// Deliberately read-only: --check runs from status paths and must never
|
||||
// mutate the tree. Journal reconciliation happens on the inject run.
|
||||
if (!fs.existsSync(CONFIG_PATH_GET())) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH_GET() }));
|
||||
process.exit(0);
|
||||
}
|
||||
let cfg;
|
||||
try {
|
||||
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH_GET(), 'utf-8'));
|
||||
} catch (err) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH_GET() }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
validateConfig(cfg);
|
||||
} catch (err) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH_GET() }));
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH_GET() }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Load config
|
||||
if (!fs.existsSync(CONFIG_PATH_GET())) {
|
||||
console.error(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH_GET() }));
|
||||
process.exit(1);
|
||||
}
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH_GET(), 'utf-8'));
|
||||
validateConfig(config);
|
||||
|
||||
const cwd = process.cwd();
|
||||
const resolvedFiles = resolveFiles(cwd, config);
|
||||
const resolved = resolveFramework(cwd, config);
|
||||
const isAdapter = resolved?.framework.inject.kind === 'adapter';
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (isAdapter) {
|
||||
const adapterResult = resolved.framework.inject.remove({ cwd, config, project: resolved.project });
|
||||
const ok = !(adapterResult && adapterResult.error);
|
||||
// Anything the adapter could not reach (its detection may have shifted
|
||||
// since the session started) is still on the journal.
|
||||
const { healed } = healInjectJournal(cwd);
|
||||
clearInjectJournal(cwd);
|
||||
console.log(JSON.stringify({
|
||||
ok,
|
||||
adapter: resolved.framework.name,
|
||||
results: [adapterResult],
|
||||
healed: healed.length ? healed : undefined,
|
||||
}));
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(cwd, relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
const detagged = removeTag(content, config.commentSyntax);
|
||||
const updated = revertCspMeta(detagged);
|
||||
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
|
||||
fs.writeFileSync(absFile, updated, 'utf-8');
|
||||
return {
|
||||
file: relFile,
|
||||
removed: detagged !== content,
|
||||
cspReverted: updated !== detagged,
|
||||
};
|
||||
});
|
||||
const { healed } = healInjectJournal(cwd);
|
||||
clearInjectJournal(cwd);
|
||||
console.log(JSON.stringify({ ok: true, results, healed: healed.length ? healed : undefined }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert mode — need --port
|
||||
const portIdx = args.indexOf('--port');
|
||||
const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : NaN;
|
||||
if (!Number.isFinite(port)) {
|
||||
console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
|
||||
process.exit(1);
|
||||
}
|
||||
// Optional server token: appended to the /live.js src so the token-gated
|
||||
// /live.js handler authorizes the browser fetch. `live.mjs` always passes
|
||||
// it; a manual `--port`-only invocation reads the running helper's token
|
||||
// from server.json instead of writing an unauthenticated URL that 401s.
|
||||
const tokenIdx = args.indexOf('--token');
|
||||
let token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
|
||||
if (!token) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', 'live', 'server.json'), 'utf-8'));
|
||||
// A record for a DIFFERENT port is a stale or foreign helper; its token
|
||||
// would 401 just the same, so only adopt a matching one.
|
||||
if (info?.token && Number(info.port) === port) token = info.token;
|
||||
} catch { /* no running helper recorded; keep legacy tokenless behavior */ }
|
||||
}
|
||||
|
||||
// Reconcile before writing anything. Artifacts this run is about to own are
|
||||
// kept (so a repeat inject stays byte-idempotent); artifacts left behind by
|
||||
// a session that never got to stop are healed.
|
||||
const plannedArtifacts = describeInjectArtifacts(resolved, { cwd, files: resolvedFiles });
|
||||
const { healed } = healInjectJournal(cwd, { keep: plannedArtifacts.map((a) => a.path) });
|
||||
|
||||
const gitIgnore = ensureLiveGitIgnores(cwd, frameworkIgnorePatterns(resolved));
|
||||
// In a nested-app repo the roots pointer lives at the REPO root, outside the
|
||||
// reach of the appRoot-relative ignore block above; give that directory its
|
||||
// own local excludes so the pointer (absolute host paths) never gets staged.
|
||||
try {
|
||||
const rootsManifest = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', 'live', 'roots.json'), 'utf-8'));
|
||||
if (rootsManifest?.repoRoot && path.resolve(rootsManifest.repoRoot) !== path.resolve(cwd)) {
|
||||
ensureLiveGitIgnores(rootsManifest.repoRoot);
|
||||
}
|
||||
} catch { /* no manifest: single-root project */ }
|
||||
|
||||
if (isAdapter) {
|
||||
const adapterResult = resolved.framework.inject.apply({
|
||||
cwd,
|
||||
port,
|
||||
token,
|
||||
config,
|
||||
project: resolved.project,
|
||||
});
|
||||
const ok = !(adapterResult && adapterResult.error);
|
||||
if (ok) recordInjection(cwd, { framework: resolved.framework.name, port, artifacts: plannedArtifacts });
|
||||
console.log(JSON.stringify({
|
||||
ok,
|
||||
port,
|
||||
adapter: resolved.framework.name,
|
||||
gitIgnore,
|
||||
results: [adapterResult],
|
||||
healed: healed.length ? healed : undefined,
|
||||
}));
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(cwd, relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
const content = fs.readFileSync(absFile, 'utf-8');
|
||||
const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
|
||||
// Per-file, not per-project: a Vite app can hold an .astro partial, and a
|
||||
// framework project's entry template is often plain HTML.
|
||||
const scriptAttrs = resolveSourceTraits(relFile).injectScriptAttrs;
|
||||
const withTag = insertTag(withoutOld, config, port, token, scriptAttrs);
|
||||
if (withTag === withoutOld) {
|
||||
return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
|
||||
}
|
||||
const updated = patchCspMeta(withTag, port);
|
||||
fs.writeFileSync(absFile, updated, 'utf-8');
|
||||
return {
|
||||
file: relFile,
|
||||
inserted: true,
|
||||
cspPatched: updated !== withTag,
|
||||
};
|
||||
});
|
||||
const anyInserted = results.some((r) => r.inserted);
|
||||
const writtenFiles = new Set(results.filter((r) => r.inserted).map((r) => r.file));
|
||||
recordInjection(cwd, {
|
||||
framework: resolved?.framework.name,
|
||||
port,
|
||||
artifacts: plannedArtifacts.filter((a) => writtenFiles.has(a.path)),
|
||||
});
|
||||
console.log(JSON.stringify({
|
||||
ok: anyInserted,
|
||||
port,
|
||||
gitIgnore,
|
||||
results,
|
||||
healed: healed.length ? healed : undefined,
|
||||
}));
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
export function ensureLiveGitIgnores(cwd = process.cwd(), extraPatterns = []) {
|
||||
const target = resolveIgnoreTarget(cwd);
|
||||
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
|
||||
const block = [
|
||||
IGNORE_MARKER_OPEN,
|
||||
...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns]),
|
||||
IGNORE_MARKER_CLOSE,
|
||||
].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_CLOSE)}`);
|
||||
|
||||
let updated;
|
||||
if (markerRe.test(existing)) {
|
||||
updated = existing.replace(markerRe, block);
|
||||
} else {
|
||||
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : existing + '\n';
|
||||
updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
|
||||
}
|
||||
|
||||
if (updated !== existing) {
|
||||
fs.mkdirSync(path.dirname(target.path), { recursive: true });
|
||||
fs.writeFileSync(target.path, updated, 'utf-8');
|
||||
}
|
||||
|
||||
return {
|
||||
file: path.relative(cwd, target.path).split(path.sep).join('/'),
|
||||
mode: target.mode,
|
||||
changed: updated !== existing,
|
||||
patterns: [...new Set([...LIVE_IGNORE_PATTERNS, ...extraPatterns])],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveIgnoreTarget(cwd) {
|
||||
const gitExcludePath = resolveGitInfoExcludePath(cwd);
|
||||
if (gitExcludePath) {
|
||||
return { path: gitExcludePath, mode: 'git-info-exclude' };
|
||||
}
|
||||
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
|
||||
}
|
||||
|
||||
function resolveGitInfoExcludePath(cwd) {
|
||||
const dotGit = path.join(cwd, '.git');
|
||||
if (!fs.existsSync(dotGit)) return null;
|
||||
|
||||
const stat = fs.statSync(dotGit);
|
||||
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
|
||||
if (!stat.isFile()) return null;
|
||||
|
||||
const body = fs.readFileSync(dotGit, 'utf-8').trim();
|
||||
const match = body.match(/^gitdir:\s*(.+)$/i);
|
||||
if (!match) return null;
|
||||
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
|
||||
return path.join(gitDir, 'info', 'exclude');
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
* glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude
|
||||
* are applied as filters. Duplicates are removed. Order is preserved by
|
||||
* first appearance.
|
||||
*/
|
||||
export function resolveFiles(rootDir, config) {
|
||||
const patterns = config.files;
|
||||
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
|
||||
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
|
||||
const excludeRegexes = allExcludes.map(globToRegex);
|
||||
|
||||
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
|
||||
const isGlob = (s) => /[*?[]/.test(s);
|
||||
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const pat of patterns) {
|
||||
if (!isGlob(pat)) {
|
||||
// Literal path — include even if it doesn't exist yet; the caller
|
||||
// reports file_not_found per-entry. Exclude list doesn't apply to
|
||||
// explicit literal entries (user named it on purpose).
|
||||
if (!seen.has(pat)) {
|
||||
seen.add(pat);
|
||||
out.push(pat);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let matches;
|
||||
try {
|
||||
matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ent of matches) {
|
||||
if (!ent.isFile || !ent.isFile()) continue;
|
||||
const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name);
|
||||
const rel = path.relative(rootDir, abs).split(path.sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
if (seen.has(rel)) continue;
|
||||
seen.add(rel);
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a glob pattern to a RegExp. Supports:
|
||||
* ** → any number of path segments (including zero)
|
||||
* * → any chars except `/`
|
||||
* ? → any single char except `/`
|
||||
* Paths are normalized to forward slashes before matching.
|
||||
*/
|
||||
function globToRegex(pattern) {
|
||||
let re = '';
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// ** — any number of segments, including zero. Handle the common
|
||||
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
|
||||
if (pattern[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (/[.+^${}()|[\]\\]/.test(c)) {
|
||||
re += '\\' + c;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validateConfig(cfg) {
|
||||
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
|
||||
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
|
||||
throw new Error('config.files (non-empty string array) required');
|
||||
}
|
||||
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.files must contain only non-empty strings');
|
||||
}
|
||||
if (cfg.exclude !== undefined) {
|
||||
if (!Array.isArray(cfg.exclude)) {
|
||||
throw new Error('config.exclude, if present, must be a string array');
|
||||
}
|
||||
if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) {
|
||||
throw new Error('config.exclude must contain only non-empty strings');
|
||||
}
|
||||
}
|
||||
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
|
||||
throw new Error('config.insertBefore or config.insertAfter (string) required');
|
||||
}
|
||||
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
|
||||
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
|
||||
}
|
||||
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
|
||||
throw new Error("config.cspChecked, if present, must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {
|
||||
enterLiveRoot();
|
||||
injectCli();
|
||||
}
|
||||
|
||||
// Re-exported so long-standing importers (live.mjs, the adapter modules, the
|
||||
// test suites) keep their entry points while the implementations live in
|
||||
// live/frameworks/.
|
||||
export {
|
||||
buildLiveScriptSrc,
|
||||
buildTagBlock,
|
||||
insertTag,
|
||||
patchCspMeta,
|
||||
removeTag,
|
||||
revertCspMeta,
|
||||
validateConfig,
|
||||
};
|
||||
export {
|
||||
applyNuxtLiveAdapter,
|
||||
buildNuxtPlugin,
|
||||
detectNuxtProject,
|
||||
removeNuxtLiveAdapter,
|
||||
} from './live/frameworks/nuxt.mjs';
|
||||
Reference in New Issue
Block a user