Merge origin/main into feat/agentic-teams
124 commits of main against 33 of agentic-teams. 22 files overlapped, 7 conflicted. Three needed real reconciliation rather than a side-pick: **The `volumes` collision.** Both branches independently added a `volumes` field to ContainerSpec meaning different things: main's is persistent storage keyed by a server-derived `claimName` that deliberately outlives an instance; mine is emptyDir scratch shared between an init container and the main container. Worse, each emitted the pod-level `volumes` list from its own helper, so spreading both would have silently kept only the last — a server with a PVC would have lost it the moment a session scratch volume appeared. Resolved by renaming the unreleased side: mine is now `scratchVolumes`, main's keeps `volumes` since it is already shipped and in use. `buildPodVolumes` now owns the whole pod-level list and emits both kinds; `buildPodExtras` is init containers only. fsGroup/securityContext stays tied to the PVC case, because an emptyDir is created writable by the container's own user and chowning it is work with no purpose. Five tests pin the coexistence so a later change cannot drop one kind again. **The extracted permission mapper.** I moved `mapUrlToPermission` out of main.ts into middleware/url-permission.ts so it could be tested; main kept it inline and kept editing it. Kept the extraction and ported main's one change across — the `/api/v1/audit/events/purge` mapping onto the existing `audit-purge` operation. Verified rule-by-rule that the extracted version is a superset: all 15 of main's rules plus the 5 agentic-teams ones. **The NUL byte in create.ts.** Both branches fixed the same defect independently, mine as `''` and main's as `'\0'`. Took main's. The rest were one-liners: .gitignore comment wording, and an add/add on proxy-long-running.test.ts where main's copy carries a progressive-delivery test mine lacked — took main's in full. Not changed, but worth flagging: main's generateDeploymentSpec hardcodes `automountServiceAccountToken: false` while generatePodSpec honours `spec.automountServiceAccountToken`. That asymmetry is main's own and predates this merge, so it is left exactly as-is rather than altered inside a merge commit. Verified: 3138 tests pass (237 files), build clean, prisma schema valid. Lint 809/80 vs 794/77 pre-merge — the delta is main's own debt; every file touched during conflict resolution was checked and introduces no new errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
@@ -19,6 +19,11 @@ source "$SCRIPT_DIR/arch-helper.sh"
|
||||
resolve_arch "${MCPCTL_TARGET_ARCH:-}"
|
||||
# Sets: NFPM_ARCH, BUN_TARGET, ARCH_SUFFIX
|
||||
|
||||
# Same guard as build-rpm.sh: this script is also run on its own, so it cannot
|
||||
# rely on that one having checked.
|
||||
source "$SCRIPT_DIR/check-main-sync.sh"
|
||||
check_main_sync
|
||||
|
||||
# Check and install missing build dependencies
|
||||
source "$SCRIPT_DIR/ensure-deps.sh"
|
||||
ensure_build_deps
|
||||
|
||||
36
scripts/build-gitea-mcp.sh
Executable file
36
scripts/build-gitea-mcp.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Build gitea-mcp Docker image and push to Gitea container registry
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Load .env for GITEA_TOKEN
|
||||
if [ -f .env ]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
# Push directly to internal address (external proxy has body size limit)
|
||||
REGISTRY="10.0.0.194:3012"
|
||||
IMAGE="gitea-mcp"
|
||||
TAG="${1:-latest}"
|
||||
|
||||
echo "==> Building gitea-mcp image..."
|
||||
podman build -t "$IMAGE:$TAG" -f deploy/Dockerfile.gitea-mcp .
|
||||
|
||||
echo "==> Tagging as $REGISTRY/michal/$IMAGE:$TAG..."
|
||||
podman tag "$IMAGE:$TAG" "$REGISTRY/michal/$IMAGE:$TAG"
|
||||
|
||||
echo "==> Logging in to $REGISTRY..."
|
||||
podman login --tls-verify=false -u michal -p "$GITEA_TOKEN" "$REGISTRY"
|
||||
|
||||
echo "==> Pushing to $REGISTRY/michal/$IMAGE:$TAG..."
|
||||
podman push --tls-verify=false "$REGISTRY/michal/$IMAGE:$TAG"
|
||||
|
||||
# Ensure package is linked to the repository
|
||||
source "$SCRIPT_DIR/link-package.sh"
|
||||
link_package "container" "$IMAGE"
|
||||
|
||||
echo "==> Done!"
|
||||
echo " Image: $REGISTRY/michal/$IMAGE:$TAG"
|
||||
@@ -16,6 +16,11 @@ if [ -f .env ]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
# This pushes an image to the registry, so the same staleness gate as the package
|
||||
# builds applies. Run standalone as well as from deploy-k8s.sh, hence its own copy.
|
||||
source "$SCRIPT_DIR/check-main-sync.sh"
|
||||
check_main_sync
|
||||
|
||||
# Push directly to internal address (external proxy has body size limit)
|
||||
REGISTRY="10.0.0.194:3012"
|
||||
IMAGE="mcpd"
|
||||
|
||||
@@ -19,6 +19,11 @@ source "$SCRIPT_DIR/arch-helper.sh"
|
||||
resolve_arch "${MCPCTL_TARGET_ARCH:-}"
|
||||
# Sets: NFPM_ARCH, BUN_TARGET, ARCH_SUFFIX
|
||||
|
||||
# Before anything expensive: a branch behind main packages a binary missing
|
||||
# whatever landed there, and installing it silently downgrades the machine.
|
||||
source "$SCRIPT_DIR/check-main-sync.sh"
|
||||
check_main_sync
|
||||
|
||||
# Check and install missing build dependencies
|
||||
source "$SCRIPT_DIR/ensure-deps.sh"
|
||||
ensure_build_deps
|
||||
|
||||
101
scripts/check-main-sync.sh
Executable file
101
scripts/check-main-sync.sh
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/bin/bash
|
||||
# Refuse to build a package from a branch that main has already moved past.
|
||||
#
|
||||
# WHY
|
||||
#
|
||||
# Everyone branches off main and builds from their own branch. A branch that is
|
||||
# behind main still builds and installs perfectly — it just quietly ships a
|
||||
# binary missing whatever landed on main in the meantime, and `rpm -U --force`
|
||||
# overwrites the good one with it.
|
||||
#
|
||||
# That is not hypothetical: on 2026-08-10 a build from a stale checkout replaced
|
||||
# /usr/bin/mcpctl with one that had no `statusline` command at all, months after
|
||||
# the status line landed on main. Nothing reported an error — the release
|
||||
# succeeded, the feature just vanished from the installed CLI.
|
||||
#
|
||||
# So this is a hard failure rather than a warning. A warning scrolls past in a
|
||||
# build log; the whole point is to stop before the artifact exists.
|
||||
#
|
||||
# ESCAPE HATCH
|
||||
#
|
||||
# MCPCTL_ALLOW_BEHIND_MAIN=1 build anyway (deliberate build of an old tree)
|
||||
# MCPCTL_BASE_BRANCH=<name> compare against something other than main
|
||||
#
|
||||
# Skips itself entirely outside a git checkout, so tarball builds still work.
|
||||
|
||||
# Resolve the ref to compare against, echoing it on stdout. Prefers a fresh
|
||||
# fetch; falls back to whatever is already on disk so an offline build is
|
||||
# degraded rather than blocked. Returns 1 when there is nothing to compare to.
|
||||
_main_sync_ref() {
|
||||
local base="$1" remote="$2"
|
||||
|
||||
if [ -n "$remote" ] && git fetch --quiet "$remote" "$base" 2>/dev/null; then
|
||||
# FETCH_HEAD rather than refs/remotes/<remote>/<base>: it is what this fetch
|
||||
# just wrote, so it cannot be a stale opportunistic update.
|
||||
echo "FETCH_HEAD"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -n "$remote" ] && git rev-parse --verify --quiet "refs/remotes/$remote/$base" >/dev/null; then
|
||||
echo " (could not reach $remote — comparing against the last fetched $remote/$base)" >&2
|
||||
echo "refs/remotes/$remote/$base"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if git rev-parse --verify --quiet "refs/heads/$base" >/dev/null; then
|
||||
echo " (no reachable remote — comparing against local $base)" >&2
|
||||
echo "refs/heads/$base"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
check_main_sync() {
|
||||
local base="${MCPCTL_BASE_BRANCH:-main}"
|
||||
|
||||
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
return 0 # not a checkout; nothing to be behind
|
||||
fi
|
||||
|
||||
if [ "${MCPCTL_ALLOW_BEHIND_MAIN:-}" = "1" ]; then
|
||||
echo "==> Skipping the '$base' sync check (MCPCTL_ALLOW_BEHIND_MAIN=1)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "==> Checking this branch is not behind '$base'..."
|
||||
|
||||
local remote ref
|
||||
remote="$(git remote | head -1)"
|
||||
if ! ref="$(_main_sync_ref "$base" "$remote")"; then
|
||||
echo " (no '$base' branch found anywhere — skipping)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local behind
|
||||
behind="$(git rev-list --count "HEAD..$ref" 2>/dev/null || echo 0)"
|
||||
if [ "$behind" -eq 0 ]; then
|
||||
echo " up to date with $base"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local branch
|
||||
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
|
||||
echo "" >&2
|
||||
echo "ERROR: '$branch' is $behind commit(s) behind $base — refusing to build." >&2
|
||||
echo "" >&2
|
||||
# Deliberately artifact-agnostic: the same helper gates RPM/DEB packages, the
|
||||
# mcpd image, and the k8s deploy.
|
||||
echo " Building now would produce an artifact without these, and shipping it" >&2
|
||||
echo " would replace a good one with a version missing them:" >&2
|
||||
echo "" >&2
|
||||
git log --oneline --no-decorate "HEAD..$ref" | head -15 | sed 's/^/ /' >&2
|
||||
if [ "$behind" -gt 15 ]; then
|
||||
echo " … and $((behind - 15)) more" >&2
|
||||
fi
|
||||
echo "" >&2
|
||||
echo " Fix it: git merge $base # or: git rebase $base" >&2
|
||||
echo " Anyway: MCPCTL_ALLOW_BEHIND_MAIN=1 $0" >&2
|
||||
echo "" >&2
|
||||
return 1
|
||||
}
|
||||
101
scripts/clean-smoke-resources.ts
Normal file
101
scripts/clean-smoke-resources.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sweep per-run smoke-test resources off the shared mcpd.
|
||||
*
|
||||
* The suites clean up in `afterAll`, which covers assertion failures but not a
|
||||
* killed process (CI timeout, Ctrl-C, OOM). Over time that leaked hundreds of
|
||||
* `smoke-*` projects and never-expiring `smoke-*` mcptokens onto the shared
|
||||
* server, to the point where the project picker was unusable.
|
||||
*
|
||||
* Run after a smoke run, or any time the server looks cluttered:
|
||||
* pnpm smoke:clean # show what would go
|
||||
* pnpm smoke:clean --yes # actually delete
|
||||
*
|
||||
* Only names matching SMOKE_PREFIX are ever considered, and the shared fixture
|
||||
* (`smoke-data` / `smoke-aws-docs`, provisioned from
|
||||
* tests/smoke/fixtures/smoke-data.yaml and depended on by five suites) is
|
||||
* protected — deleting it mid-run would break a concurrent suite.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const SMOKE_PREFIX = /^smoke-/;
|
||||
|
||||
/** Shared, intentionally long-lived fixture — never swept. */
|
||||
const PROTECTED = new Set(['smoke-data', 'smoke-aws-docs']);
|
||||
|
||||
const APPLY = process.argv.includes('--yes') || process.argv.includes('-y');
|
||||
|
||||
interface Named { id?: string; name?: string; projectName?: string; status?: string }
|
||||
|
||||
function mcpctl(args: string[]): string {
|
||||
return execFileSync('mcpctl', args, { encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 });
|
||||
}
|
||||
|
||||
function list(resource: string, extra: string[] = []): Named[] {
|
||||
try {
|
||||
const out = mcpctl(['get', resource, '-o', 'json', ...extra]);
|
||||
const parsed: unknown = JSON.parse(out || '[]');
|
||||
return Array.isArray(parsed) ? (parsed as Named[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function sweepable(rows: Named[]): Named[] {
|
||||
return rows.filter((r) => typeof r.name === 'string' && SMOKE_PREFIX.test(r.name) && !PROTECTED.has(r.name));
|
||||
}
|
||||
|
||||
function remove(resource: string, row: Named, extra: string[] = []): boolean {
|
||||
const name = row.name ?? row.id ?? '';
|
||||
if (!SMOKE_PREFIX.test(name) || PROTECTED.has(name)) return false; // belt and braces
|
||||
try {
|
||||
mcpctl(['delete', resource, name, ...extra]);
|
||||
return true;
|
||||
} catch (err) {
|
||||
process.stderr.write(` ! ${resource} ${name}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const projects = sweepable(list('projects'));
|
||||
// Tokens live on real projects too (the suites mint smoke-* tokens against
|
||||
// mcpctl-development and sre), so they need sweeping independently of projects.
|
||||
const tokens = sweepable(list('mcptoken'));
|
||||
const servers = sweepable(list('servers'));
|
||||
const llms = sweepable(list('llms'));
|
||||
const agents = sweepable(list('agents'));
|
||||
|
||||
const groups: Array<[string, Named[], string[]]> = [
|
||||
['project', projects, []],
|
||||
['mcptoken', tokens, []],
|
||||
['server', servers, []],
|
||||
['llm', llms, []],
|
||||
['agent', agents, []],
|
||||
];
|
||||
|
||||
let total = 0;
|
||||
for (const [, rows] of groups) total += rows.length;
|
||||
|
||||
if (total === 0) {
|
||||
process.stdout.write('smoke:clean — nothing to sweep\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const [kind, rows] of groups) {
|
||||
if (rows.length === 0) continue;
|
||||
process.stdout.write(`${kind}: ${String(rows.length)}\n`);
|
||||
for (const r of rows.slice(0, 5)) process.stdout.write(` ${r.name ?? ''}${r.projectName ? ` (in ${r.projectName})` : ''}\n`);
|
||||
if (rows.length > 5) process.stdout.write(` … and ${String(rows.length - 5)} more\n`);
|
||||
}
|
||||
|
||||
if (!APPLY) {
|
||||
process.stdout.write(`\n${String(total)} resource(s) would be deleted. Re-run with --yes to apply.\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let deleted = 0;
|
||||
for (const [kind, rows, extra] of groups) {
|
||||
for (const r of rows) if (remove(kind, r, extra)) deleted++;
|
||||
}
|
||||
process.stdout.write(`\nsmoke:clean — deleted ${String(deleted)}/${String(total)}\n`);
|
||||
process.exit(deleted === total ? 0 : 1);
|
||||
@@ -80,6 +80,20 @@ cat <<EOF
|
||||
EOF
|
||||
[ -f "$PULUMI_YAML" ] || die "Pulumi config not found: $PULUMI_YAML"
|
||||
|
||||
# ── 0. Staleness gate ──
|
||||
# Same hazard as the RPM build, with the cluster on the receiving end: a branch
|
||||
# behind main deploys images missing whatever landed there, and the sha pinned in
|
||||
# Pulumi makes that the new source of truth. Skipped for --dry-run, which builds
|
||||
# and cuts over nothing — blocking a read-only inspection only teaches people to
|
||||
# export MCPCTL_ALLOW_BEHIND_MAIN=1 permanently, which would disable the gate for
|
||||
# the real deploys too.
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
warn "dry-run: skip the main-sync check"
|
||||
else
|
||||
source "$SCRIPT_DIR/check-main-sync.sh"
|
||||
check_main_sync || die "branch is behind main — merge it before deploying"
|
||||
fi
|
||||
|
||||
# ── 1. Test gate ──
|
||||
if [ "$SKIP_TESTS" = true ]; then warn "skipping unit tests (--skip-tests)"; else
|
||||
say "1/7 Unit tests (pnpm test:run)"
|
||||
@@ -173,14 +187,16 @@ trap - ERR
|
||||
|
||||
# ── 7. RPM + smoke ──
|
||||
say "7/7 Build/install CLI RPM + smoke tests"
|
||||
bash scripts/release.sh
|
||||
systemctl --user restart mcplocal && sleep 2
|
||||
if pnpm test:smoke > /tmp/deploy-smoke.log 2>&1; then
|
||||
grep -E "Tests |passed" /tmp/deploy-smoke.log | tail -2
|
||||
# release.sh already restarts mcplocal and runs the smoke suite against the
|
||||
# binary it just installed. This step used to restart and re-run it a second
|
||||
# time, which put two full suites inside a minute and tripped mcpd's rate
|
||||
# limiter: the second run failed with 429s and printed a false
|
||||
# "SMOKE TESTS FAILED — consider rollback" over a perfectly healthy deploy.
|
||||
# One run, one verdict.
|
||||
if bash scripts/release.sh; then
|
||||
say "Deploy complete — $TAG live. Rollback tag: $ROLLBACK_TAG"
|
||||
else
|
||||
tail -40 /tmp/deploy-smoke.log
|
||||
warn "SMOKE TESTS FAILED — system may be unhealthy. Consider rollback:"
|
||||
warn "RELEASE OR SMOKE TESTS FAILED — system may be unhealthy. Consider rollback:"
|
||||
rollback_recipe
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -76,7 +76,7 @@ const PROJECT_ONLY_COMMANDS = new Set([
|
||||
|
||||
/** Commands that appear in BOTH project and non-project context. */
|
||||
const PROJECT_SCOPED_COMMANDS = new Set([
|
||||
'get', 'describe', 'delete', 'logs', 'create', 'edit', 'help',
|
||||
'get', 'describe', 'delete', 'logs', 'create', 'edit', 'help', 'trace',
|
||||
]);
|
||||
|
||||
/** Completely hidden commands (never shown in completions). */
|
||||
|
||||
62
scripts/generate-opencode-extension.ts
Normal file
62
scripts/generate-opencode-extension.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates `src/cli/src/config/opencode-extension.ts`, which embeds the two
|
||||
* opencode plugin sources as string constants — mirroring how the pi extension
|
||||
* (`pi-extension.ts`) and prime-agent's `/mcpctl` switcher
|
||||
* (`prime-agent-extension.ts`) are embedded.
|
||||
*
|
||||
* Embedding matters: `mcpctl config opencode` must work from an installed
|
||||
* binary that has no access to the source tree. The installed plugin files are
|
||||
* this exact embedded source, so what the CLI ships is always what opencode
|
||||
* runs.
|
||||
*
|
||||
* Regenerate after editing the plugin sources:
|
||||
* npx tsx scripts/generate-opencode-extension.ts
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const scriptsDir = import.meta.dirname;
|
||||
const root = join(scriptsDir, '..');
|
||||
const extDir = join(root, 'src', 'opencode-ext');
|
||||
|
||||
const SERVER = readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8');
|
||||
const TUI = readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8');
|
||||
|
||||
function embed(src) {
|
||||
// JSON.stringify yields a quoted string literal we can inline directly.
|
||||
return JSON.stringify(src);
|
||||
}
|
||||
|
||||
const out = `/**
|
||||
* Embedded source of the mcpctl opencode plugins — DO NOT EDIT BY HAND.
|
||||
* Generated by \`npx tsx scripts/generate-opencode-extension.ts\` from
|
||||
* \`src/opencode-ext/mcpctl-opencode.ts\` and
|
||||
* \`src/opencode-ext/mcpctl-opencode-tui.tsx\`.
|
||||
*
|
||||
* \`mcpctl config opencode\` writes these verbatim into ~/.config/opencode/
|
||||
* (and registers them), so an installed binary with no source tree can still
|
||||
* provision a working opencode integration.
|
||||
*
|
||||
* The install file names differ from the source file names on purpose:
|
||||
* - the server plugin lands in opencode's auto-discovered \`plugin/\` dir,
|
||||
* where its basename is what shows up in \`opencode plugin list\`;
|
||||
* - the TUI plugin lands beside it in \`mcpctl/\`, referenced by path from
|
||||
* \`tui.json\`, and must keep a \`.tsx\` extension for opencode to transpile
|
||||
* its JSX.
|
||||
*/
|
||||
|
||||
/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */
|
||||
export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts';
|
||||
|
||||
/** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */
|
||||
export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx';
|
||||
|
||||
export const OPENCODE_SERVER_PLUGIN_SOURCE: string = ${embed(SERVER)};
|
||||
|
||||
export const OPENCODE_TUI_PLUGIN_SOURCE: string = ${embed(TUI)};
|
||||
`;
|
||||
|
||||
mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true });
|
||||
writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'opencode-extension.ts'), out);
|
||||
console.log('wrote src/cli/src/config/opencode-extension.ts');
|
||||
48
scripts/generate-pi-extension.ts
Normal file
48
scripts/generate-pi-extension.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates `src/cli/src/config/pi-extension.ts` which embeds the pi extension
|
||||
* sources (`mcpctl-pi.ts` + `mcp-http.ts`) as string constants, mirroring how
|
||||
* prime-agent's `/mcpctl` switcher is embedded (`prime-agent-extension.ts`).
|
||||
*
|
||||
* Embedding matters: `mcpctl config pi` must work from an installed binary
|
||||
* that has no access to the source tree. The installed extension files are this
|
||||
* exact embedded source, so what the CLI ships is always what pi runs.
|
||||
*
|
||||
* Regenerate after editing the extension sources:
|
||||
* npx tsx scripts/generate-pi-extension.ts
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const scriptsDir = import.meta.dirname;
|
||||
const root = join(scriptsDir, '..');
|
||||
const piExtDir = join(root, 'src', 'pi-ext');
|
||||
|
||||
const MAIN = readFileSync(join(piExtDir, 'mcpctl-pi.ts'), 'utf-8');
|
||||
const HTTP = readFileSync(join(piExtDir, 'mcp-http.ts'), 'utf-8');
|
||||
|
||||
function embed(src) {
|
||||
// JSON.stringify yields a quoted string literal we can inline directly.
|
||||
return JSON.stringify(src);
|
||||
}
|
||||
|
||||
const out = `/**
|
||||
* Embedded source of the mcpctl pi extension — DO NOT EDIT BY HAND.
|
||||
* Generated by \`npx tsx scripts/generate-pi-extension.ts\` from
|
||||
* \`src/pi-ext/mcpctl-pi.ts\` and \`src/pi-ext/mcp-http.ts\`.
|
||||
*
|
||||
* \`mcpctl config pi\` writes these verbatim into ~/.pi/agent/extensions/mcpctl/
|
||||
* (and registers them), so an installed binary with no source tree can still
|
||||
* provision a working pi integration.
|
||||
*/
|
||||
export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const;
|
||||
|
||||
export const PI_EXTENSION_FILES: Record<string, string> = {
|
||||
'mcpctl-pi.ts': ${embed(MAIN)},
|
||||
'mcp-http.ts': ${embed(HTTP)},
|
||||
};
|
||||
`;
|
||||
|
||||
mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true });
|
||||
writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'pi-extension.ts'), out);
|
||||
console.log('wrote src/cli/src/config/pi-extension.ts');
|
||||
41
scripts/generate-prime-agent-extension.ts
Normal file
41
scripts/generate-prime-agent-extension.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates `src/cli/src/config/prime-agent-extension.ts`, which embeds the
|
||||
* prime-agent `/mcpctl` switcher as a string constant — the same arrangement
|
||||
* the pi and opencode extensions use.
|
||||
*
|
||||
* The switcher used to live *only* as that string literal, with no source file
|
||||
* behind it, so nothing typechecked or linted it. Now `src/prime-agent-ext/`
|
||||
* holds the real source and this script produces the embed, so the file the CLI
|
||||
* writes into ~/.prime/agent/extensions/ is always exactly what was checked.
|
||||
*
|
||||
* Regenerate after editing the extension source:
|
||||
* npx tsx scripts/generate-prime-agent-extension.ts
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const scriptsDir = import.meta.dirname;
|
||||
const root = join(scriptsDir, '..');
|
||||
const extDir = join(root, 'src', 'prime-agent-ext');
|
||||
|
||||
const SOURCE = readFileSync(join(extDir, 'mcpctl-switch.ts'), 'utf-8');
|
||||
|
||||
const out = `/**
|
||||
* The source of the \`/mcpctl\` project-switcher extension, exported as a string
|
||||
* so \`mcpctl config prime-agent\` can install it into prime-agent's auto-
|
||||
* discovered extensions directory (\`~/.prime/agent/extensions/\`).
|
||||
*
|
||||
* DO NOT EDIT BY HAND. Generated by
|
||||
* \`npx tsx scripts/generate-prime-agent-extension.ts\` from
|
||||
* \`src/prime-agent-ext/mcpctl-switch.ts\` — edit that instead, so the change is
|
||||
* typechecked before it ships. The installed file is this exact source
|
||||
* (verbatim), so the extension the CLI writes is always the one that ran.
|
||||
*/
|
||||
export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts';
|
||||
export const MCPCTL_SWITCH_EXTENSION = ${JSON.stringify(SOURCE)};
|
||||
`;
|
||||
|
||||
mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true });
|
||||
writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'prime-agent-extension.ts'), out);
|
||||
console.log('wrote src/cli/src/config/prime-agent-extension.ts');
|
||||
@@ -75,9 +75,28 @@ echo "==> Running smoke tests..."
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
if pnpm test:smoke; then
|
||||
echo "==> Smoke tests passed!"
|
||||
elif [ "${MCPCTL_ALLOW_SMOKE_FAILURE:-}" = "1" ]; then
|
||||
echo "==> WARNING: Smoke tests failed, continuing (MCPCTL_ALLOW_SMOKE_FAILURE=1)."
|
||||
else
|
||||
echo "==> WARNING: Smoke tests failed! Check mcplocal/mcpd are running."
|
||||
echo " Continuing anyway — deployment is complete, but verify manually."
|
||||
# This used to print a warning and exit 0. That is how four broken readiness
|
||||
# probes shipped unnoticed on 2026-08-10: the warning scrolled past in the
|
||||
# build log and the release reported success. A failing smoke run means
|
||||
# something in the live fleet is genuinely broken — say so in the exit code.
|
||||
#
|
||||
# Note what this does and does not do: smoke runs LAST, against the installed
|
||||
# binary, so the package is already published and installed by now. Failing
|
||||
# here reports the breakage, it does not prevent it — investigate, do not
|
||||
# assume the artifact is bad.
|
||||
echo "" >&2
|
||||
echo "ERROR: smoke tests failed — the release is published and installed, but" >&2
|
||||
echo " something in the live fleet is broken. Investigate before relying" >&2
|
||||
echo " on this build; do not just re-run." >&2
|
||||
echo "" >&2
|
||||
echo " Common causes: mcplocal/mcpd not running, a readiness probe pointing at" >&2
|
||||
echo " a tool the upstream renamed, or an expired credential." >&2
|
||||
echo " Override: MCPCTL_ALLOW_SMOKE_FAILURE=1 $0" >&2
|
||||
echo "" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user