@nullplatform/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +252 -0
  3. package/dist/config.js +26 -0
  4. package/dist/git.js +27 -0
  5. package/dist/http.js +330 -0
  6. package/dist/i18n.js +595 -0
  7. package/dist/index.js +72 -0
  8. package/dist/md.js +110 -0
  9. package/dist/np/auth.js +130 -0
  10. package/dist/np/client.js +72 -0
  11. package/dist/np/context.js +201 -0
  12. package/dist/np/journey.js +403 -0
  13. package/dist/prompts.js +64 -0
  14. package/dist/render.js +236 -0
  15. package/dist/server.js +91 -0
  16. package/dist/skills.js +84 -0
  17. package/dist/surfaces/developer.js +29 -0
  18. package/dist/surfaces/index.js +17 -0
  19. package/dist/surfaces/surface.js +1 -0
  20. package/dist/tool-names.js +25 -0
  21. package/dist/tool.js +92 -0
  22. package/dist/tools/approvals.js +80 -0
  23. package/dist/tools/builds.js +94 -0
  24. package/dist/tools/create-app.js +187 -0
  25. package/dist/tools/create-release.js +52 -0
  26. package/dist/tools/create-scope.js +82 -0
  27. package/dist/tools/deploy.js +178 -0
  28. package/dist/tools/find-apps.js +36 -0
  29. package/dist/tools/index.js +39 -0
  30. package/dist/tools/logs.js +83 -0
  31. package/dist/tools/metrics.js +83 -0
  32. package/dist/tools/overview.js +110 -0
  33. package/dist/tools/params.js +58 -0
  34. package/dist/tools/playbook.js +39 -0
  35. package/dist/tools/services.js +58 -0
  36. package/dist/tools/set-params.js +58 -0
  37. package/dist/tools/shared.js +141 -0
  38. package/dist/tools/status.js +70 -0
  39. package/dist/tools/traffic.js +74 -0
  40. package/dist/ui.js +76 -0
  41. package/package.json +65 -0
  42. package/skills/deploying-safely/SKILL.md +54 -0
  43. package/skills/incident-response/SKILL.md +52 -0
  44. package/skills/platform-conventions/SKILL.md +61 -0
  45. package/widgets-dist/create-app.html +830 -0
  46. package/widgets-dist/find-apps.html +831 -0
  47. package/widgets-dist/logs.html +830 -0
  48. package/widgets-dist/manifest.json +8 -0
  49. package/widgets-dist/metrics.html +829 -0
  50. package/widgets-dist/np-panel.html +831 -0
  51. package/widgets-dist/params.html +829 -0
package/dist/index.js ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { createServer } from "node:http";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { loadConfig } from "./config.js";
6
+ import { createMcpHttpHandler, hardenServerTimeouts } from "./http.js";
7
+ import { resolveLocale, setDefaultLocale } from "./i18n.js";
8
+ import { buildDeps, buildServer } from "./server.js";
9
+ import { selectSurface } from "./surfaces/index.js";
10
+ // Minimal .env loader (dev convenience; real installs pass env via the MCP client config).
11
+ function loadDotEnv() {
12
+ try {
13
+ for (const line of readFileSync(".env", "utf8").split("\n")) {
14
+ const entry = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
15
+ const key = entry?.[1];
16
+ if (key && entry[2] !== undefined && !(key in process.env)) {
17
+ process.env[key] = entry[2].replace(/^["']|["']$/g, "");
18
+ }
19
+ }
20
+ }
21
+ catch {
22
+ /* no .env — fine */
23
+ }
24
+ }
25
+ async function main() {
26
+ loadDotEnv();
27
+ // stdio speaks the operator's language; HTTP resolves Accept-Language per request.
28
+ setDefaultLocale(resolveLocale(process.env.NP_LANG ?? process.env.LANG));
29
+ // Which audience this process serves; a sibling package would default NP_SURFACE differently.
30
+ const surface = selectSurface(process.env.NP_SURFACE);
31
+ const httpFlag = process.argv.indexOf("--http");
32
+ if (httpFlag !== -1) {
33
+ const portArg = process.argv[httpFlag + 1];
34
+ const port = portArg && /^\d+$/.test(portArg) ? Number(portArg) : 3000;
35
+ // Multi-user: refuses to start with credentials in the environment — callers bring their own.
36
+ const config = loadConfig(process.env, "forbid");
37
+ const allowedOrigins = (process.env.NP_ALLOWED_ORIGINS ?? "")
38
+ .split(",")
39
+ .map((origin) => origin.trim())
40
+ .filter(Boolean);
41
+ const rateLimitEnv = process.env.NP_RATE_LIMIT_RPM?.trim();
42
+ const handler = createMcpHttpHandler(config, {
43
+ allowedOrigins,
44
+ surface,
45
+ ...(rateLimitEnv && /^\d+$/.test(rateLimitEnv) ? { rateLimitRpm: Number(rateLimitEnv) } : {}),
46
+ trustProxy: process.env.NP_TRUST_PROXY === "1" || process.env.NP_TRUST_PROXY === "true",
47
+ });
48
+ const http = createServer((request, response) => void handler(request, response));
49
+ // Slowloris guard: bound how long a client may take to deliver a request.
50
+ hardenServerTimeouts(http);
51
+ http.listen(port, () => {
52
+ console.error(`[ai-mcp] streamable HTTP on :${port}/mcp (${surface.key}) — per-user auth (Authorization: Bearer <NP_API_KEY>)`);
53
+ // Every request carries a bearer credential; plain HTTP would expose it in cleartext.
54
+ console.error("[ai-mcp] SECURITY: terminate TLS in a reverse proxy on a trusted network — never expose this port directly over plain HTTP.");
55
+ });
56
+ return;
57
+ }
58
+ // stdio (default) — single user, their key from the env; stdout is the protocol channel.
59
+ // Widgets register only if this client negotiates the MCP Apps extension.
60
+ const config = loadConfig(process.env, "require");
61
+ const server = buildServer(buildDeps(config), { uiRegistration: "negotiated", surface });
62
+ await server.connect(new StdioServerTransport());
63
+ console.error(`[ai-mcp] ready (stdio, ${surface.key})`);
64
+ }
65
+ process.on("unhandledRejection", (reason) => {
66
+ // Never crash a serving process on a stray promise; surface it on stderr (stdout is protocol).
67
+ console.error("[ai-mcp] unhandled rejection:", reason);
68
+ });
69
+ main().catch((caught) => {
70
+ console.error(`[ai-mcp] ${caught instanceof Error ? caught.message : caught}`);
71
+ process.exit(1);
72
+ });
package/dist/md.js ADDED
@@ -0,0 +1,110 @@
1
+ import { translate } from "./i18n.js";
2
+ /**
3
+ * The output design language. Every tool answer is markdown a developer scans in a
4
+ * terminal or chat pane: a bold header line, tight tables, one status glyph per row,
5
+ * and a single "Next:" hint so the obvious follow-up is one step away.
6
+ */
7
+ const GLYPHS = {
8
+ // healthy / done
9
+ active: "🟢",
10
+ successful: "🟢",
11
+ finalized: "🟢",
12
+ running: "🟢",
13
+ // in flight
14
+ pending: "⏳",
15
+ creating: "⏳",
16
+ updating: "⏳",
17
+ building: "⏳",
18
+ waiting_for_instances: "⏳",
19
+ switching_traffic: "🔵",
20
+ finalizing: "⏳",
21
+ cancelling: "⏳",
22
+ rolling_back: "⏳",
23
+ creating_approval: "✋",
24
+ // bad / terminal
25
+ failed: "🔴",
26
+ cancelled: "⚪",
27
+ rolled_back: "↩️",
28
+ deleted: "⚪",
29
+ inactive: "⚪",
30
+ creating_approval_denied: "🚫",
31
+ };
32
+ export function glyph(status) {
33
+ if (!status)
34
+ return "·";
35
+ return GLYPHS[status] ?? "·";
36
+ }
37
+ /** Localized status word when the catalog knows it; humanized raw status otherwise. */
38
+ function statusWord(status) {
39
+ const key = `status.${status}`;
40
+ return status in GLYPHS ? translate(key) : status.replace(/_/g, " ");
41
+ }
42
+ export function statusLabel(status) {
43
+ if (!status)
44
+ return translate("md.unknown");
45
+ return `${glyph(status)} ${statusWord(status)}`;
46
+ }
47
+ export function ago(iso) {
48
+ if (!iso)
49
+ return "";
50
+ const elapsedMs = Date.now() - new Date(iso).getTime();
51
+ if (!Number.isFinite(elapsedMs) || elapsedMs < 0)
52
+ return "";
53
+ const minutes = Math.floor(elapsedMs / 60_000);
54
+ if (minutes < 1)
55
+ return translate("md.justNow");
56
+ if (minutes < 60)
57
+ return translate("md.minutesAgo", { count: minutes });
58
+ const hours = Math.round(minutes / 60);
59
+ if (hours < 48)
60
+ return translate("md.hoursAgo", { count: hours });
61
+ return translate("md.daysAgo", { count: Math.round(hours / 24) });
62
+ }
63
+ export function table(headers, rows) {
64
+ if (rows.length === 0)
65
+ return translate("md.none");
66
+ const head = `| ${headers.join(" | ")} |`;
67
+ const separator = `|${headers.map(() => "---").join("|")}|`;
68
+ const body = rows.map((row) => `| ${row.join(" | ")} |`).join("\n");
69
+ return [head, separator, body].join("\n");
70
+ }
71
+ export function next(hint) {
72
+ return `\n→ **${translate("md.next")}:** ${hint}`;
73
+ }
74
+ const SPARK = "▁▂▃▄▅▆▇█";
75
+ /** Unicode sparkline — the terminal-native chart. */
76
+ export function spark(values, buckets = 24) {
77
+ if (!values.length)
78
+ return "";
79
+ const step = Math.max(1, Math.ceil(values.length / buckets));
80
+ const sampled = [];
81
+ for (let index = 0; index < values.length; index += step) {
82
+ const chunk = values.slice(index, index + step);
83
+ sampled.push(chunk.reduce((sum, value) => sum + value, 0) / chunk.length);
84
+ }
85
+ const minimum = Math.min(...sampled);
86
+ const maximum = Math.max(...sampled);
87
+ const range = maximum - minimum || 1;
88
+ return sampled.map((value) => SPARK[Math.min(7, Math.floor(((value - minimum) / range) * 8))]).join("");
89
+ }
90
+ export function fmtNum(value) {
91
+ if (value === undefined || value === null || Number.isNaN(value))
92
+ return "—";
93
+ if (Math.abs(value) >= 100)
94
+ return String(Math.round(value));
95
+ if (Math.abs(value) >= 1)
96
+ return value.toFixed(1);
97
+ return value.toFixed(2);
98
+ }
99
+ export function shortCommit(commit) {
100
+ return commit ? String(commit).slice(0, 8) : "";
101
+ }
102
+ /** Deep link into the web dashboard for any entity NRN (the dashboard has an /:nrn redirect). */
103
+ export function dashboardLink(orgSlug, nrn) {
104
+ if (!orgSlug || !nrn)
105
+ return undefined;
106
+ return `https://${orgSlug}.app.nullplatform.io/nrn/${encodeURIComponent(nrn)}`;
107
+ }
108
+ export function linkLine(label, url) {
109
+ return url ? `\n[${label}](${url})` : "";
110
+ }
@@ -0,0 +1,130 @@
1
+ import { translate } from "../i18n.js";
2
+ /** Pull the nullplatform organization id out of a Cognito JWT's cognito:groups claim. */
3
+ export function orgIdFromJwt(jwt) {
4
+ try {
5
+ const payloadSegment = jwt.split(".")[1];
6
+ if (!payloadSegment)
7
+ return undefined;
8
+ // JWT segments are base64url; Node's base64 decoder tolerates it, but be explicit.
9
+ const payload = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8"));
10
+ for (const group of payload["cognito:groups"] ?? []) {
11
+ const orgMatch = /@nullplatform\/organization=(\d+)/.exec(group);
12
+ if (orgMatch)
13
+ return Number(orgMatch[1]);
14
+ }
15
+ }
16
+ catch {
17
+ /* not a decodable JWT */
18
+ }
19
+ return undefined;
20
+ }
21
+ /** The platform said no to this credential — an auth-boundary error, not a tool error. */
22
+ export class CredentialRejectedError extends Error {
23
+ constructor(message) {
24
+ super(message ?? translate("error.credentialRejected", { status: 401 }));
25
+ this.name = "CredentialRejectedError";
26
+ }
27
+ }
28
+ export class TokenManager {
29
+ options;
30
+ fetchImpl;
31
+ accessToken;
32
+ refreshToken;
33
+ expiresAt = 0;
34
+ inflight;
35
+ organizationId;
36
+ constructor(options, fetchImpl = fetch) {
37
+ this.options = options;
38
+ this.fetchImpl = fetchImpl;
39
+ if (options.bearer) {
40
+ this.accessToken = options.bearer;
41
+ this.expiresAt = Number.MAX_SAFE_INTEGER;
42
+ this.organizationId = orgIdFromJwt(options.bearer);
43
+ }
44
+ }
45
+ async getToken() {
46
+ if (this.options.bearer)
47
+ return this.options.bearer;
48
+ const skewMs = 60_000;
49
+ if (this.accessToken && Date.now() < this.expiresAt - skewMs)
50
+ return this.accessToken;
51
+ // Single-flight: concurrent callers share one exchange — the api_key crosses
52
+ // the wire once, and the platform never sees a burst of parallel exchanges.
53
+ this.inflight ??= (this.refreshToken ? this.refresh() : this.exchange()).finally(() => {
54
+ this.inflight = undefined;
55
+ });
56
+ return this.inflight;
57
+ }
58
+ invalidate() {
59
+ if (this.options.bearer)
60
+ return; // can't refresh a static token
61
+ this.accessToken = undefined;
62
+ this.expiresAt = 0;
63
+ }
64
+ async exchange() {
65
+ if (!this.options.apiKey)
66
+ throw new Error("no api_key configured for token exchange");
67
+ return this.call({ api_key: this.options.apiKey });
68
+ }
69
+ async refresh() {
70
+ try {
71
+ return await this.call({
72
+ refresh_token: this.refreshToken,
73
+ organization_id: String(this.organizationId),
74
+ });
75
+ }
76
+ catch {
77
+ this.refreshToken = undefined; // fall back to api_key
78
+ return this.exchange();
79
+ }
80
+ }
81
+ async call(body) {
82
+ const exchanged = await callTokenEndpoint(this.options.apiBase, body, this.fetchImpl);
83
+ this.accessToken = exchanged.accessToken;
84
+ this.refreshToken = exchanged.refreshToken ?? this.refreshToken;
85
+ this.organizationId = exchanged.organizationId ?? this.organizationId;
86
+ this.expiresAt = exchanged.expiresAt;
87
+ return exchanged.accessToken;
88
+ }
89
+ }
90
+ /**
91
+ * One api_key -> access-token exchange, stateless: the key exists only in this call's
92
+ * scope. Multi-user servers use this so the customer's key is never retained — each
93
+ * request brings it again, and only the platform-issued token is worth caching.
94
+ */
95
+ export async function exchangeApiKey(apiBase, apiKey, fetchImpl = fetch) {
96
+ return callTokenEndpoint(apiBase, { api_key: apiKey }, fetchImpl);
97
+ }
98
+ /** Token exchanges must time out like every other platform call — a hung /token
99
+ * otherwise pins the request and leaks the HTTP layer's in-flight verification entry. */
100
+ const TOKEN_EXCHANGE_TIMEOUT_MS = 20_000;
101
+ /** A short, whitespace-collapsed slice of an upstream body — never dump a full payload into
102
+ * logs or error text (secret hygiene: credential-adjacent failures stay terse and bounded). */
103
+ function bodySnippet(text, max = 200) {
104
+ const collapsed = text.replace(/\s+/g, " ").trim();
105
+ return collapsed.length > max ? `${collapsed.slice(0, max)}…` : collapsed;
106
+ }
107
+ async function callTokenEndpoint(apiBase, body, fetchImpl) {
108
+ const response = await fetchImpl(`${apiBase}/token`, {
109
+ signal: AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS),
110
+ method: "POST",
111
+ headers: { "content-type": "application/json", accept: "application/json" },
112
+ body: JSON.stringify(body),
113
+ });
114
+ if (response.status === 401 || response.status === 403) {
115
+ // Don't echo the response body here — keep credential failures terse and log-safe.
116
+ throw new CredentialRejectedError(translate("error.credentialRejected", { status: response.status }));
117
+ }
118
+ if (!response.ok) {
119
+ // Bound the upstream body: operator-log-only, but a hostile/huge body must not flood the
120
+ // logs, and credential-adjacent failures stay terse per the secret-hygiene contract.
121
+ throw new Error(`token exchange failed: ${response.status} ${bodySnippet(await response.text())}`);
122
+ }
123
+ const data = (await response.json());
124
+ return {
125
+ accessToken: data.access_token,
126
+ organizationId: data.organization_id,
127
+ expiresAt: data.token_expires_at ?? Date.now() + 3_600_000,
128
+ refreshToken: data.refresh_token,
129
+ };
130
+ }
@@ -0,0 +1,72 @@
1
+ export class NpApiError extends Error {
2
+ status;
3
+ body;
4
+ constructor(status, body, message) {
5
+ super(message);
6
+ this.status = status;
7
+ this.body = body;
8
+ this.name = "NpApiError";
9
+ }
10
+ }
11
+ /**
12
+ * Thin client for the nullplatform public API (api.nullplatform.com).
13
+ * Paths are UNVERSIONED and bodies are snake_case — pass them through as-is.
14
+ */
15
+ export class NpClient {
16
+ opts;
17
+ fetchImpl;
18
+ constructor(opts, fetchImpl = fetch) {
19
+ this.opts = opts;
20
+ this.fetchImpl = fetchImpl;
21
+ }
22
+ url(path, query) {
23
+ const queryString = query
24
+ ? Object.entries(query)
25
+ .filter(([, value]) => value !== undefined && value !== null)
26
+ .map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(String(value))}`)
27
+ .join("&")
28
+ : "";
29
+ return `${this.opts.apiBase}${path}${queryString ? `?${queryString}` : ""}`;
30
+ }
31
+ async request(method, path, query, body, retry = true) {
32
+ const token = await this.opts.getToken();
33
+ const extra = this.opts.getExtraHeaders ? await this.opts.getExtraHeaders() : {};
34
+ const res = await this.fetchImpl(this.url(path, query), {
35
+ signal: AbortSignal.timeout(this.opts.timeoutMs ?? 20_000),
36
+ method,
37
+ headers: {
38
+ Authorization: `Bearer ${token}`,
39
+ "content-type": "application/json",
40
+ accept: "application/json",
41
+ ...extra,
42
+ },
43
+ body: body === undefined ? undefined : JSON.stringify(body),
44
+ });
45
+ if (res.status === 401 && retry && this.opts.onUnauthorized) {
46
+ await this.opts.onUnauthorized();
47
+ return this.request(method, path, query, body, false);
48
+ }
49
+ const text = await res.text();
50
+ const parsed = text ? safeJson(text) : null;
51
+ if (!res.ok)
52
+ throw new NpApiError(res.status, parsed, `${method} ${path} -> ${res.status}`);
53
+ return parsed;
54
+ }
55
+ get(path, query) {
56
+ return this.request("GET", path, query);
57
+ }
58
+ post(path, body, query) {
59
+ return this.request("POST", path, query, body);
60
+ }
61
+ patch(path, body, query) {
62
+ return this.request("PATCH", path, query, body);
63
+ }
64
+ }
65
+ function safeJson(text) {
66
+ try {
67
+ return JSON.parse(text);
68
+ }
69
+ catch {
70
+ return text;
71
+ }
72
+ }
@@ -0,0 +1,201 @@
1
+ import { normalizeRepoUrl, repoName } from "../git.js";
2
+ /** Base of a new application's repository, from the account's git provider + org prefix
3
+ * (e.g. provider "github" + prefix "acme" -> "https://github.com/acme"). Undefined when the
4
+ * account has no provider configured — the create form then asks for a full URL instead. */
5
+ export function baseRepoUrl(provider, prefix) {
6
+ if (!prefix)
7
+ return undefined;
8
+ const host = provider === "gitlab"
9
+ ? "https://gitlab.com"
10
+ : provider === "azure" || provider === "azure-devops"
11
+ ? "https://dev.azure.com"
12
+ : "https://github.com";
13
+ return `${host}/${prefix}`;
14
+ }
15
+ /** Default ceiling for a single app search — used as the per-namespace fetch cap AND the
16
+ * overall result cap. Beyond it the tool reports truncation and asks the user to narrow.
17
+ * (The old value of 50 silently hid larger orgs behind a flat "50 applications".) */
18
+ export const DEFAULT_APP_LIMIT = 200;
19
+ /** Concurrency-limited parallel map — fast fan-out without hammering the API. */
20
+ export async function pmap(items, mapItem, limit = 12) {
21
+ const results = new Array(items.length);
22
+ let nextIndex = 0;
23
+ const worker = async () => {
24
+ while (nextIndex < items.length) {
25
+ const index = nextIndex++;
26
+ results[index] = await mapItem(items[index]); // index < items.length by loop guard
27
+ }
28
+ };
29
+ await Promise.all(Array.from({ length: Math.min(limit, items.length || 1) }, worker));
30
+ return results;
31
+ }
32
+ /**
33
+ * The public list endpoints are NRN-authorized and need explicit scoping (account needs
34
+ * organization_id, namespace needs account_id, application needs namespace_id). There is no
35
+ * org-wide app list, so we fan out in parallel and cache the account+namespace skeleton so
36
+ * repeated lookups are near-instant within a session.
37
+ */
38
+ export class NpContext {
39
+ np;
40
+ skeleton = null;
41
+ appByRepo = new Map();
42
+ orgSlug;
43
+ orgSlugFetched = false;
44
+ static CACHE_TTL_MS = 120_000;
45
+ constructor(np) {
46
+ this.np = np;
47
+ }
48
+ async organizationId() {
49
+ const me = await this.np.get("/token");
50
+ return me.organization_id;
51
+ }
52
+ async getSkeleton(refresh = false) {
53
+ if (!refresh && this.skeleton && Date.now() - this.skeleton.at < NpContext.CACHE_TTL_MS)
54
+ return this.skeleton;
55
+ const orgId = await this.organizationId();
56
+ const accountsResp = await this.np.get("/account", {
57
+ organization_id: orgId,
58
+ limit: 200,
59
+ });
60
+ const perAccount = await pmap(accountsResp.results ?? [], async (account) => {
61
+ const namespacesPage = await this.np
62
+ .get("/namespace", { account_id: account.id, limit: 200 })
63
+ .catch(() => ({ results: [] }));
64
+ const accountBaseRepoUrl = baseRepoUrl(account.repository_provider, account.repository_prefix);
65
+ return (namespacesPage.results ?? []).map((namespace) => ({
66
+ id: namespace.id,
67
+ name: namespace.name,
68
+ nrn: namespace.nrn,
69
+ account_id: account.id,
70
+ account_name: account.name,
71
+ base_repo_url: accountBaseRepoUrl,
72
+ }));
73
+ });
74
+ this.skeleton = { orgId, at: Date.now(), namespaces: perAccount.flat() };
75
+ return this.skeleton;
76
+ }
77
+ /** Best-effort org slug for dashboard deep links; the API may not expose it — degrade silently. */
78
+ async organizationSlug() {
79
+ if (this.orgSlugFetched)
80
+ return this.orgSlug;
81
+ this.orgSlugFetched = true;
82
+ try {
83
+ const orgId = await this.organizationId();
84
+ const org = await this.np.get(`/organization/${orgId}`);
85
+ this.orgSlug = org?.slug ?? undefined;
86
+ }
87
+ catch {
88
+ this.orgSlug = undefined;
89
+ }
90
+ return this.orgSlug;
91
+ }
92
+ mapApp(raw, namespace) {
93
+ return {
94
+ id: raw.id,
95
+ name: raw.name,
96
+ status: raw.status,
97
+ nrn: raw.nrn,
98
+ repository_url: raw.repository_url,
99
+ namespace_id: namespace?.id ?? raw.namespace_id,
100
+ namespace: namespace?.name,
101
+ account: namespace?.account_name,
102
+ };
103
+ }
104
+ /** Search apps by partial name (and/or namespace name) across the whole org — parallel + cached. */
105
+ async findApps(args = {}) {
106
+ const skeleton = await this.getSkeleton();
107
+ const namespaceFilter = args.namespace?.toLowerCase();
108
+ const namespaces = namespaceFilter
109
+ ? skeleton.namespaces.filter((namespace) => namespace.name.toLowerCase().includes(namespaceFilter))
110
+ : skeleton.namespaces;
111
+ const nameFilter = args.query?.toLowerCase();
112
+ const perNamespace = await pmap(namespaces, async (namespace) => {
113
+ try {
114
+ const query = {
115
+ namespace_id: namespace.id,
116
+ limit: DEFAULT_APP_LIMIT,
117
+ };
118
+ if (args.query)
119
+ query["name:contains"] = args.query; // server-side when supported
120
+ const page = await this.np.get("/application", query);
121
+ return (page.results ?? []).map((app) => this.mapApp(app, namespace));
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ });
127
+ const seen = new Set();
128
+ return perNamespace
129
+ .flat()
130
+ .filter((app) => !nameFilter || (app.name ?? "").toLowerCase().includes(nameFilter)) // client-side fallback filter
131
+ .filter((app) => app.status !== "deleted")
132
+ .filter((app) => {
133
+ if (seen.has(app.id))
134
+ return false;
135
+ seen.add(app.id);
136
+ return true;
137
+ })
138
+ .slice(0, args.limit ?? DEFAULT_APP_LIMIT);
139
+ }
140
+ /** Match the current git repo to a nullplatform application by repository_url (then by name). */
141
+ async inferAppFromRepo(repoUrl) {
142
+ const key = normalizeRepoUrl(repoUrl);
143
+ const cached = this.appByRepo.get(key);
144
+ if (cached)
145
+ return cached;
146
+ const all = await this.findApps({});
147
+ const byUrl = all.find((candidate) => candidate.repository_url && normalizeRepoUrl(candidate.repository_url) === key);
148
+ // Only the repository_url match is authoritative — cache it. A name-only fallback is a
149
+ // best-effort guess (two apps can share a name across namespaces, and list filtering can
150
+ // leave the wrong sole survivor), so return it but never persist it for the session.
151
+ if (byUrl) {
152
+ this.appByRepo.set(key, byUrl);
153
+ return byUrl;
154
+ }
155
+ const name = repoName(repoUrl);
156
+ const byName = all.filter((candidate) => candidate.name.toLowerCase() === name.toLowerCase());
157
+ return byName.length === 1 ? byName[0] : undefined;
158
+ }
159
+ async getApp(applicationId) {
160
+ const raw = await this.np.get(`/application/${applicationId}`);
161
+ return { ...this.mapApp(raw), messages: raw.messages ?? [] };
162
+ }
163
+ }
164
+ /**
165
+ * Unified app resolution used by every tool:
166
+ * - `app` numeric or "#123" -> fetch by id
167
+ * - `app` string -> org-wide name search
168
+ * - nothing -> infer from the workspace git remote
169
+ */
170
+ export async function resolveApp(context, args, repoUrl) {
171
+ const reference = args.app;
172
+ if (reference !== undefined && reference !== null && String(reference).trim() !== "") {
173
+ const idMatch = /^#?(\d+)$/.exec(String(reference).trim());
174
+ if (idMatch) {
175
+ try {
176
+ return { ok: true, app: await context.getApp(Number(idMatch[1])) };
177
+ }
178
+ catch {
179
+ return { ok: false, reason: "not_found", cause: "id", ref: idMatch[1] };
180
+ }
181
+ }
182
+ const matches = await context.findApps({ query: String(reference), limit: 10 });
183
+ if (matches.length === 1)
184
+ return { ok: true, app: matches[0] };
185
+ if (matches.length > 1) {
186
+ const exact = matches.filter((candidate) => candidate.name.toLowerCase() === String(reference).toLowerCase());
187
+ if (exact.length === 1)
188
+ return { ok: true, app: exact[0] };
189
+ return { ok: false, reason: "ambiguous", matches };
190
+ }
191
+ return { ok: false, reason: "not_found", cause: "name", ref: String(reference) };
192
+ }
193
+ const url = await repoUrl();
194
+ if (url) {
195
+ const app = await context.inferAppFromRepo(url);
196
+ if (app)
197
+ return { ok: true, app };
198
+ return { ok: false, reason: "not_found", cause: "repo_unlinked", url };
199
+ }
200
+ return { ok: false, reason: "not_found", cause: "no_input" };
201
+ }