feat(FN-3377): add llama.cpp provider integration with pi extension and mob
This merge adds a new llama.cpp local AI provider integration, including a `pi-llama-cpp` CLI extension package with model resolution and context retrieval, a `LlamaCppProviderCard` UI component for provider configuration, backend API routes for model registration and llama.cpp probe/health checking Fusion-Task-Id: FN-3377
This commit is contained in:
5
.changeset/fix-issue-33-dockerode-missing.md
Normal file
5
.changeset/fix-issue-33-dockerode-missing.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Harden the publish path against dockerode-class missing-dependency regressions (#33). Adds a generalized invariant test that walks `tsup.config.ts` and asserts every non-builtin `external` is either a runtime dep or in an explicit transitive-allowlist, plus a pre-publish smoke step in `pnpm release` that packs the public tarballs, installs them with plain `npm` into a clean temp dir, and invokes the bin — catching the dockerode-class bug (and others like missing `files` globs) before publish, since pnpm hoisting masks it in the workspace.
|
||||||
5
.changeset/launcher-update-notice.md
Normal file
5
.changeset/launcher-update-notice.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"runfusion.ai": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Surface "new version available" notices to users who run `npx runfusion.ai` without ever opening the dashboard. The launcher now reads the existing `~/.fusion/update-check.json` cache (written by the dashboard's update-check service) and prints a one-line stderr notice when a newer Fusion is published. When the cache is missing or older than 24h, a fire-and-forget fetch against the npm registry refreshes it (1.5s timeout) so non-dashboard users still pick up updates on their next run. Disable with `FUSION_NO_UPDATE_CHECK=1`; auto-skipped in CI and non-TTY contexts.
|
||||||
@@ -11,7 +11,10 @@
|
|||||||
// verbatim so behavior matches the main CLI exactly (e.g. bare `fn` prints
|
// verbatim so behavior matches the main CLI exactly (e.g. bare `fn` prints
|
||||||
// help, not `fn dashboard`).
|
// help, not `fn dashboard`).
|
||||||
|
|
||||||
import { basename } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
const args = globalThis.process.argv.slice(2);
|
const args = globalThis.process.argv.slice(2);
|
||||||
const invokedAs = basename(globalThis.process.argv[1] || "").replace(/\.(js|cjs|mjs|exe)$/i, "");
|
const invokedAs = basename(globalThis.process.argv[1] || "").replace(/\.(js|cjs|mjs|exe)$/i, "");
|
||||||
@@ -22,4 +25,128 @@ if (isAliasInvocation && args.length === 0) {
|
|||||||
globalThis.process.argv = [globalThis.process.argv[0], globalThis.process.argv[1], "dashboard"];
|
globalThis.process.argv = [globalThis.process.argv[0], globalThis.process.argv[1], "dashboard"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maybeAnnounceUpdateAndRefresh();
|
||||||
|
|
||||||
await import("@runfusion/fusion/dist/bin.js");
|
await import("@runfusion/fusion/dist/bin.js");
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Update notice & background refresh.
|
||||||
|
//
|
||||||
|
// Reads the existing `~/.fusion/update-check.json` cache that the dashboard
|
||||||
|
// server writes (packages/dashboard/src/update-check.ts). If a newer version
|
||||||
|
// is available, prints a one-line stderr notice. To avoid coupling the
|
||||||
|
// launcher to the dashboard bundle and to keep it fast, we never import
|
||||||
|
// dashboard code here — we just consume the cache file by its known shape.
|
||||||
|
//
|
||||||
|
// If the cache is missing or older than 24h, fires a non-blocking fetch
|
||||||
|
// against the npm registry and rewrites the cache so users who never open
|
||||||
|
// the dashboard still pick up updates eventually. Skipped in CI, when
|
||||||
|
// stderr isn't a TTY, or when FUSION_NO_UPDATE_CHECK=1 is set.
|
||||||
|
function maybeAnnounceUpdateAndRefresh() {
|
||||||
|
try {
|
||||||
|
if (process.env.FUSION_NO_UPDATE_CHECK === "1") return;
|
||||||
|
if (process.env.CI) return;
|
||||||
|
if (!process.stderr.isTTY) return;
|
||||||
|
|
||||||
|
const fusionDir = resolveFusionDir();
|
||||||
|
const cachePath = join(fusionDir, "update-check.json");
|
||||||
|
const currentVersion = readBundledFusionVersion();
|
||||||
|
|
||||||
|
let cache = null;
|
||||||
|
try {
|
||||||
|
cache = JSON.parse(readFileSync(cachePath, "utf-8"));
|
||||||
|
} catch { /* missing or corrupt — treat as no cache */ }
|
||||||
|
|
||||||
|
if (
|
||||||
|
cache &&
|
||||||
|
cache.updateAvailable === true &&
|
||||||
|
typeof cache.latestVersion === "string" &&
|
||||||
|
cache.currentVersion === currentVersion
|
||||||
|
) {
|
||||||
|
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
||||||
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
||||||
|
process.stderr.write(
|
||||||
|
yellow(
|
||||||
|
`\nFusion ${cache.latestVersion} is available (you have ${currentVersion}).\n`,
|
||||||
|
) +
|
||||||
|
dim(` Update: npx runfusion.ai@latest\n Disable: FUSION_NO_UPDATE_CHECK=1\n\n`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const stale =
|
||||||
|
!cache ||
|
||||||
|
typeof cache.lastChecked !== "number" ||
|
||||||
|
Date.now() - cache.lastChecked > DAY_MS ||
|
||||||
|
cache.currentVersion !== currentVersion;
|
||||||
|
if (stale && currentVersion) {
|
||||||
|
backgroundRefresh(fusionDir, cachePath, currentVersion).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Update check is best-effort — never block or fail the launcher.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFusionDir() {
|
||||||
|
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
||||||
|
const preferred = join(home, ".fusion");
|
||||||
|
if (existsSync(preferred)) return preferred;
|
||||||
|
const legacy = join(home, ".pi", "fusion");
|
||||||
|
if (existsSync(legacy)) return legacy;
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBundledFusionVersion() {
|
||||||
|
try {
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const pkgPath = require.resolve("@runfusion/fusion/package.json");
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||||
|
return typeof pkg.version === "string" ? pkg.version : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backgroundRefresh(fusionDir, cachePath, currentVersion) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 1500);
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://registry.npmjs.org/@runfusion%2Ffusion", {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = await response.json();
|
||||||
|
const latestVersion = payload?.["dist-tags"]?.latest;
|
||||||
|
if (typeof latestVersion !== "string") return;
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
updateAvailable: isRemoteNewer(latestVersion, currentVersion),
|
||||||
|
lastChecked: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
mkdirSync(fusionDir, { recursive: true });
|
||||||
|
writeFileSync(cachePath, JSON.stringify(result, null, 2), "utf-8");
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRemoteNewer(remote, current) {
|
||||||
|
const parse = (v) =>
|
||||||
|
String(v)
|
||||||
|
.split(".")
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((p) => Number.parseInt(p, 10))
|
||||||
|
.map((n) => (Number.isFinite(n) ? n : 0));
|
||||||
|
const r = parse(remote);
|
||||||
|
const c = parse(current);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if ((r[i] ?? 0) > (c[i] ?? 0)) return true;
|
||||||
|
if ((r[i] ?? 0) < (c[i] ?? 0)) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { builtinModules } from "node:module";
|
||||||
import { parse } from "yaml";
|
import { parse } from "yaml";
|
||||||
|
|
||||||
const workspaceRoot = join(__dirname, "..", "..", "..", "..");
|
const workspaceRoot = join(__dirname, "..", "..", "..", "..");
|
||||||
@@ -79,12 +80,101 @@ describe("CLI package.json publishing config", () => {
|
|||||||
expect(deps).toContain("ioredis");
|
expect(deps).toContain("ioredis");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("declares dockerode as a runtime dependency when kept external in CLI bundling", () => {
|
// Generalized guard derived from tsup.config.ts. Any non-builtin module
|
||||||
const deps = Object.keys(pkg.dependencies || {});
|
// marked `external` MUST be a runtime dep (so `npm install @runfusion/fusion`
|
||||||
const devDeps = Object.keys(pkg.devDependencies || {});
|
// can resolve it after publish), and any module pulled in via `noExternal`
|
||||||
|
// (i.e. inlined into the bundle) MUST NOT leak into runtime deps.
|
||||||
|
// pnpm hoisting masks the missing-dep case in the workspace, so a hardcoded
|
||||||
|
// allowlist isn't enough — this iterates the live config instead.
|
||||||
|
describe("tsup external/noExternal vs published deps", () => {
|
||||||
|
const tsupRaw = readFileSync(
|
||||||
|
join(workspaceRoot, "packages", "cli", "tsup.config.ts"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
expect(deps).toContain("dockerode");
|
function extractStringArray(name: string): string[] {
|
||||||
expect(devDeps).not.toContain("dockerode");
|
const m = tsupRaw.match(new RegExp(`${name}:\\s*\\[([\\s\\S]*?)\\]`, "m"));
|
||||||
|
if (!m) return [];
|
||||||
|
return [...m[1].matchAll(/["']([^"']+)["']/g)].map((mm) => mm[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRegexes(name: string): RegExp[] {
|
||||||
|
const m = tsupRaw.match(new RegExp(`${name}:\\s*\\[([\\s\\S]*?)\\]`, "m"));
|
||||||
|
if (!m) return [];
|
||||||
|
// Match `/PATTERN/flags` where PATTERN may contain escaped slashes (`\/`).
|
||||||
|
return [...m[1].matchAll(/\/((?:\\\/|[^/\n])+)\/[gimsuy]*/g)].map(
|
||||||
|
(mm) => new RegExp(mm[1].replace(/\\\//g, "/")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const externals = extractStringArray("external");
|
||||||
|
const noExternalRegexes = extractRegexes("noExternal");
|
||||||
|
const noExternalStrings = extractStringArray("noExternal");
|
||||||
|
|
||||||
|
// Externals that intentionally aren't direct deps. Each entry needs a reason —
|
||||||
|
// when adding to this list, document *why* it doesn't need to be a runtime dep
|
||||||
|
// (transitive via another dep, only used by the Bun binary, etc.) so future
|
||||||
|
// edits don't silently re-introduce the dockerode-class bug.
|
||||||
|
const TRANSITIVE_EXTERNALS: Record<string, string> = {
|
||||||
|
ssh2: "transitive dep of dockerode",
|
||||||
|
"cpu-features": "transitive dep of dockerode (via ssh2)",
|
||||||
|
"node-pty": "only loaded by the Bun-compiled binary from dist/runtime/",
|
||||||
|
"@homebridge/node-pty-prebuilt-multiarch":
|
||||||
|
"only loaded by the Bun-compiled binary from dist/runtime/",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("parses externals from tsup.config.ts", () => {
|
||||||
|
expect(externals.length).toBeGreaterThan(0);
|
||||||
|
expect(externals).toContain("dockerode");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(externals.filter(
|
||||||
|
(e) =>
|
||||||
|
!builtinModules.includes(e) &&
|
||||||
|
!e.startsWith("node:") &&
|
||||||
|
!(e in TRANSITIVE_EXTERNALS),
|
||||||
|
))(
|
||||||
|
'external "%s" is declared as a runtime dependency',
|
||||||
|
(external) => {
|
||||||
|
const deps = Object.keys(pkg.dependencies || {});
|
||||||
|
const devDeps = Object.keys(pkg.devDependencies || {});
|
||||||
|
expect(
|
||||||
|
deps,
|
||||||
|
`tsup external "${external}" must be in @runfusion/fusion dependencies — otherwise \`npx runfusion.ai\` fails with ERR_MODULE_NOT_FOUND on a clean install. If this is a transitive dep, add it to TRANSITIVE_EXTERNALS with a reason.`,
|
||||||
|
).toContain(external);
|
||||||
|
expect(
|
||||||
|
devDeps,
|
||||||
|
`tsup external "${external}" must not be only a devDependency`,
|
||||||
|
).not.toContain(external);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("TRANSITIVE_EXTERNALS entries still appear in tsup external (otherwise stale)", () => {
|
||||||
|
for (const name of Object.keys(TRANSITIVE_EXTERNALS)) {
|
||||||
|
expect(
|
||||||
|
externals,
|
||||||
|
`TRANSITIVE_EXTERNALS["${name}"] is no longer in tsup external — remove the allowlist entry.`,
|
||||||
|
).toContain(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("noExternal (bundled) modules are not also runtime deps", () => {
|
||||||
|
const deps = Object.keys(pkg.dependencies || {});
|
||||||
|
for (const dep of deps) {
|
||||||
|
for (const re of noExternalRegexes) {
|
||||||
|
expect(
|
||||||
|
re.test(dep),
|
||||||
|
`dep "${dep}" matches noExternal pattern ${re} — bundled code should not also be a runtime dep`,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
for (const s of noExternalStrings) {
|
||||||
|
expect(
|
||||||
|
dep,
|
||||||
|
`dep "${dep}" is listed in noExternal — bundled code should not also be a runtime dep`,
|
||||||
|
).not.toBe(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
// pnpm release --dry-run # preview only — exit before any file/git/npm changes
|
// pnpm release --dry-run # preview only — exit before any file/git/npm changes
|
||||||
|
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { readFileSync, readdirSync, writeFileSync, statSync, existsSync, unlinkSync, mkdtempSync } from "node:fs";
|
import { readFileSync, readdirSync, writeFileSync, statSync, existsSync, unlinkSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import { stdin, stdout } from "node:process";
|
import { stdin, stdout } from "node:process";
|
||||||
@@ -279,6 +279,104 @@ function escapeRegex(s) {
|
|||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pack @runfusion/fusion and runfusion.ai, install them into a clean temp dir
|
||||||
|
* with plain `npm` (mimicking the `npx runfusion.ai` install path), and invoke
|
||||||
|
* the bin with --help. Throws via fail() on any error.
|
||||||
|
*
|
||||||
|
* Why this exists: the workspace install hides missing-from-published-deps
|
||||||
|
* bugs because pnpm hoists devDeps. Issue #33 (dockerode missing in published
|
||||||
|
* dependencies) shipped because no check ever ran against a real npm install.
|
||||||
|
*/
|
||||||
|
function runReleaseSmoke() {
|
||||||
|
const repoRoot = resolve(".");
|
||||||
|
const fusionDir = join(repoRoot, "packages", "cli");
|
||||||
|
const aliasDir = join(repoRoot, "packages", "cli-alias");
|
||||||
|
const smokeDir = mkdtempSync(join(tmpdir(), "fusion-smoke-"));
|
||||||
|
const packDir = join(smokeDir, "tarballs");
|
||||||
|
spawnSync("mkdir", ["-p", packDir]);
|
||||||
|
|
||||||
|
const packOne = (cwd) => {
|
||||||
|
const r = spawnSync("pnpm", ["pack", "--pack-destination", packDir], {
|
||||||
|
cwd,
|
||||||
|
stdio: "pipe",
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
if (r.status !== 0) {
|
||||||
|
cleanupSmoke(smokeDir);
|
||||||
|
fail(`pnpm pack failed in ${cwd}:\n${r.stderr || r.stdout}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
packOne(fusionDir);
|
||||||
|
packOne(aliasDir);
|
||||||
|
|
||||||
|
const tarballs = readdirSync(packDir).filter((f) => f.endsWith(".tgz"));
|
||||||
|
const fusionTarball = tarballs.find((f) => f.startsWith("runfusion-fusion-"));
|
||||||
|
const aliasTarball = tarballs.find((f) => f.startsWith("runfusion.ai-"));
|
||||||
|
if (!fusionTarball || !aliasTarball) {
|
||||||
|
cleanupSmoke(smokeDir);
|
||||||
|
fail(`Could not find packed tarballs in ${packDir}: ${tarballs.join(", ")}`);
|
||||||
|
}
|
||||||
|
const fusionTarballPath = join(packDir, fusionTarball);
|
||||||
|
const aliasTarballPath = join(packDir, aliasTarball);
|
||||||
|
|
||||||
|
const installDir = join(smokeDir, "install");
|
||||||
|
spawnSync("mkdir", ["-p", installDir]);
|
||||||
|
// Override @runfusion/fusion to the local tarball — without this, npm tries
|
||||||
|
// to fetch the version-matching tarball from the registry (which we haven't
|
||||||
|
// published yet).
|
||||||
|
writeFileSync(
|
||||||
|
join(installDir, "package.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: "fusion-smoke-test",
|
||||||
|
version: "0.0.0",
|
||||||
|
private: true,
|
||||||
|
overrides: { "@runfusion/fusion": `file:${fusionTarballPath}` },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const npmInstall = spawnSync(
|
||||||
|
"npm",
|
||||||
|
["install", "--no-audit", "--no-fund", "--ignore-scripts", aliasTarballPath],
|
||||||
|
{ cwd: installDir, stdio: "pipe", encoding: "utf8" },
|
||||||
|
);
|
||||||
|
if (npmInstall.status !== 0) {
|
||||||
|
cleanupSmoke(smokeDir);
|
||||||
|
fail(`npm install of packed tarballs failed:\n${npmInstall.stderr || npmInstall.stdout}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke the bin via the alias entry. Exercises the same import graph as
|
||||||
|
// `npx runfusion.ai` and surfaces ERR_MODULE_NOT_FOUND for any externalized
|
||||||
|
// module that isn't a real published dep (the dockerode bug).
|
||||||
|
const aliasBin = join(installDir, "node_modules", "runfusion.ai", "index.js");
|
||||||
|
if (!existsSync(aliasBin)) {
|
||||||
|
cleanupSmoke(smokeDir);
|
||||||
|
fail(`Smoke install missing alias bin at ${aliasBin}`);
|
||||||
|
}
|
||||||
|
const invoke = spawnSync("node", [aliasBin, "--help"], {
|
||||||
|
cwd: installDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
if (invoke.status !== 0) {
|
||||||
|
cleanupSmoke(smokeDir);
|
||||||
|
fail(
|
||||||
|
`Packed bin failed to start (exit ${invoke.status}):\n--- stdout ---\n${invoke.stdout}\n--- stderr ---\n${invoke.stderr}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupSmoke(smokeDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupSmoke(dir) {
|
||||||
|
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
function findPackageDir(name) {
|
function findPackageDir(name) {
|
||||||
// Most packages live under packages/<basename>; do an exact match on package.json name.
|
// Most packages live under packages/<basename>; do an exact match on package.json name.
|
||||||
const roots = ["packages"];
|
const roots = ["packages"];
|
||||||
@@ -400,6 +498,17 @@ run(
|
|||||||
{ allowFail: true }
|
{ allowFail: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Pre-publish smoke ----------------------------------------------------
|
||||||
|
// Pack the public CLI tarballs, install them with plain `npm` into a clean
|
||||||
|
// temp dir, and exercise the bin to verify a real `npx runfusion.ai` install
|
||||||
|
// would succeed. Catches missing-published-deps (dockerode-class), missing
|
||||||
|
// files-glob entries, broken bin shebangs, etc. that the workspace install
|
||||||
|
// masks via pnpm hoisting.
|
||||||
|
|
||||||
|
info("Running pre-publish smoke (pack + clean-install + invoke bin)…");
|
||||||
|
runReleaseSmoke();
|
||||||
|
ok("Pre-publish smoke passed.");
|
||||||
|
|
||||||
// --- Publish --------------------------------------------------------------
|
// --- Publish --------------------------------------------------------------
|
||||||
|
|
||||||
info("Publishing to npm (non-private packages only)…");
|
info("Publishing to npm (non-private packages only)…");
|
||||||
|
|||||||
Reference in New Issue
Block a user