@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
@@ -0,0 +1,94 @@
1
+ import { z } from "zod";
2
+ import { translate } from "../i18n.js";
3
+ import { ago, glyph, next, shortCommit, table } from "../md.js";
4
+ import { listAssets, listBuilds, listReleases } from "../np/journey.js";
5
+ import { defineTool, reply } from "../tool.js";
6
+ import { TOOL } from "../tool-names.js";
7
+ import { appArg, requireApp } from "./shared.js";
8
+ /**
9
+ * Closes the hole in the ship loop: between "push a commit" and "deploy" sits CI, and the
10
+ * agent needs to see builds land. status only shows the latest; this lists recent builds,
11
+ * whether each is released, and (on request) a build's assets.
12
+ */
13
+ export const buildsTool = defineTool({
14
+ name: TOOL.applicationBuildList,
15
+ title: "List builds",
16
+ description: "List an application's recent CI builds — status, branch, commit, age, and whether each is already released. Use it to answer 'did my push build yet?' and to pick a build_id to deploy. Pass build:<id> to see that build's assets.",
17
+ annotations: { readOnlyHint: true, openWorldHint: true },
18
+ errorKey: "builds.errorLabel",
19
+ inputSchema: {
20
+ app: appArg,
21
+ build: z.number().optional().describe("Build id — show this build's assets instead of the list"),
22
+ limit: z.number().optional().describe("How many recent builds (default 10)"),
23
+ },
24
+ async handler(args, context) {
25
+ const resolved = await requireApp(context, args);
26
+ if ("out" in resolved)
27
+ return resolved.out;
28
+ const app = resolved.app;
29
+ if (args.build) {
30
+ const assets = await listAssets(context.np, args.build);
31
+ if (assets.length === 0) {
32
+ return reply(translate("builds.noAssets", { build: args.build }), {
33
+ build_id: args.build,
34
+ assets: [],
35
+ });
36
+ }
37
+ const markdown = [
38
+ translate("builds.assetsTitle", { build: args.build }),
39
+ "",
40
+ table([translate("header.asset"), translate("header.type"), translate("header.platform")], assets.map((asset) => [asset.name, asset.type, asset.platform ?? ""])),
41
+ next(translate("builds.deployHint", { build: args.build })),
42
+ ].join("\n");
43
+ return reply(markdown, { build_id: args.build, assets });
44
+ }
45
+ const [builds, releases] = await Promise.all([
46
+ listBuilds(context.np, app.id, args.limit ?? 10),
47
+ listReleases(context.np, app.id, { limit: 50 }),
48
+ ]);
49
+ if (builds.length === 0) {
50
+ return reply(translate("builds.none", { app: app.name }) + next(translate("builds.noneHint")));
51
+ }
52
+ const releasedBuildIds = new Set(releases.map((release) => release.build_id).filter(Boolean));
53
+ const releaseByBuild = new Map(releases.map((release) => [release.build_id, release]));
54
+ const markdown = [
55
+ translate("builds.title", { app: app.name, count: builds.length }),
56
+ "",
57
+ table([
58
+ translate("header.id"),
59
+ translate("header.status"),
60
+ translate("builds.branch"),
61
+ translate("builds.commit"),
62
+ translate("header.when"),
63
+ translate("builds.released"),
64
+ ], builds.map((build) => [
65
+ `#${build.id}`,
66
+ `${glyph(build.status)} ${build.status}`,
67
+ build.branch ?? "",
68
+ shortCommit(build.commit),
69
+ ago(build.created_at),
70
+ releaseByBuild.get(build.id)?.semver ?? (releasedBuildIds.has(build.id) ? "✓" : ""),
71
+ ])),
72
+ next(buildsHint(builds, releasedBuildIds)),
73
+ ].join("\n");
74
+ return reply(markdown, {
75
+ app: `#${app.id}`,
76
+ app_name: app.name,
77
+ builds: builds.map((build) => ({
78
+ ...build,
79
+ released: releasedBuildIds.has(build.id),
80
+ release_semver: releaseByBuild.get(build.id)?.semver ?? null,
81
+ })),
82
+ });
83
+ },
84
+ });
85
+ function buildsHint(builds, releasedBuildIds) {
86
+ const newestSuccessful = builds.find((build) => build.status === "successful");
87
+ if (newestSuccessful && !releasedBuildIds.has(newestSuccessful.id)) {
88
+ return translate("builds.deployHint", { build: newestSuccessful.id });
89
+ }
90
+ const inProgress = builds.find((build) => /building|pending|in_progress|running|queued/.test(build.status));
91
+ if (inProgress)
92
+ return translate("builds.waiting", { build: inProgress.id });
93
+ return translate("builds.upToDate");
94
+ }
@@ -0,0 +1,187 @@
1
+ import { z } from "zod";
2
+ import { translate } from "../i18n.js";
3
+ import { dashboardLink, glyph, linkLine, next } from "../md.js";
4
+ import { createApplication, getApplication, listTemplates } from "../np/journey.js";
5
+ import { defineTool, fail, reply } from "../tool.js";
6
+ import { TOOL } from "../tool-names.js";
7
+ import { delays, httpsRepoUrl, sleep } from "./shared.js";
8
+ /** Namespaces + templates the widget needs to drive the create form. */
9
+ async function buildFormReply(context, prefill) {
10
+ const skeleton = await context.org.getSkeleton();
11
+ // Templates are scoped by NRN: a NAMESPACE's NRN surfaces the org/account-owned templates on
12
+ // top of the global set. (The org-level NRN returns a near-empty, misleading subset — the
13
+ // widget refetches per namespace by calling create_app again with the chosen namespace_id.)
14
+ const forNamespace = prefill.namespace_id
15
+ ? skeleton.namespaces.find((candidate) => candidate.id === prefill.namespace_id)
16
+ : skeleton.namespaces[0];
17
+ const templates = await listTemplates(context.np, forNamespace?.nrn).catch(() => []);
18
+ const namespaces = skeleton.namespaces.map((namespace) => ({
19
+ id: namespace.id,
20
+ name: namespace.name,
21
+ account: namespace.account_name,
22
+ base_url: namespace.base_repo_url ?? null,
23
+ }));
24
+ return reply(translate("createApp.form", {
25
+ namespaces: namespaces.map((namespace) => namespace.name).join(", ") || "—",
26
+ }), {
27
+ mode: "form",
28
+ namespaces,
29
+ templates: templates.map((template) => ({
30
+ id: template.id,
31
+ name: template.name,
32
+ tags: template.tags ?? [],
33
+ })),
34
+ prefill: {
35
+ name: prefill.name ?? null,
36
+ // Echo the namespace the templates were fetched for so the widget's selection matches.
37
+ namespace_id: prefill.namespace_id ?? forNamespace?.id ?? null,
38
+ repository_url: prefill.repository_url ?? null,
39
+ },
40
+ });
41
+ }
42
+ export const createAppTool = defineTool({
43
+ name: TOOL.applicationCreate,
44
+ title: "Create application",
45
+ description: "Create a nullplatform application. Calling with NO repository_url opens an interactive form and creates nothing — the user picks a namespace, then either a NEW repository (scaffolded from a template, repo URL auto-generated) or IMPORTS an existing repository (optionally a monorepo folder), and the form calls back with a repository_url to actually create. Only an explicit repository_url performs the create, so opening the form never writes anything. Provisioning is async — the create waits briefly and reports progress.",
46
+ annotations: { destructiveHint: false, openWorldHint: true },
47
+ widget: "create-app",
48
+ errorKey: "createApp.errorLabel",
49
+ inputSchema: {
50
+ name: z.string().optional().describe("App name (default: the repo name)"),
51
+ namespace: z.string().optional().describe("Namespace name (prefer namespace_id when known)"),
52
+ namespace_id: z.number().optional().describe("Namespace id to create the app in"),
53
+ repository_url: z
54
+ .string()
55
+ .optional()
56
+ .describe("Full git repo URL. New repos: auto-generated from the account + name. Import: the existing URL."),
57
+ template_id: z
58
+ .number()
59
+ .optional()
60
+ .describe("Scaffolding template id — required for a brand-new repository"),
61
+ repository_app_path: z
62
+ .string()
63
+ .optional()
64
+ .describe("Import only: the folder inside a monorepo that holds this application"),
65
+ },
66
+ async handler(args, context) {
67
+ // Create ONLY on an explicit repository_url (the form's Create button, or a deliberate
68
+ // call). With none, return the form and create nothing. create_app does NOT read the
69
+ // ambient git remote: opening the form, switching namespace, or running the MCP inside some
70
+ // repo must never auto-create an app behind the user's back (the bug where "let's create an
71
+ // app" silently created the MCP's own repo). The user chooses the repository in the form.
72
+ const repoUrl = args.repository_url ? httpsRepoUrl(args.repository_url) : undefined;
73
+ if (!repoUrl) {
74
+ return buildFormReply(context, {
75
+ name: args.name,
76
+ namespace_id: args.namespace_id,
77
+ repository_url: null,
78
+ });
79
+ }
80
+ // Convergent under retries: a repo already linked to an app returns that app, never a duplicate.
81
+ const alreadyLinked = await context.org.inferAppFromRepo(repoUrl);
82
+ if (alreadyLinked) {
83
+ const orgSlug = await context.org.organizationSlug();
84
+ return reply(translate("createApp.alreadyLinked", {
85
+ name: alreadyLinked.name,
86
+ id: alreadyLinked.id,
87
+ repo: repoUrl,
88
+ }) +
89
+ next(translate("createApp.createdHint")) +
90
+ linkLine(translate("md.dashboard"), dashboardLink(orgSlug, alreadyLinked.nrn)), {
91
+ application: {
92
+ id: alreadyLinked.id,
93
+ name: alreadyLinked.name,
94
+ status: alreadyLinked.status,
95
+ nrn: alreadyLinked.nrn,
96
+ },
97
+ reused: true,
98
+ });
99
+ }
100
+ const name = args.name ??
101
+ repoUrl
102
+ .split("/")
103
+ .pop()
104
+ ?.replace(/\.git$/, "");
105
+ if (!name)
106
+ return fail(translate("createApp.noName", { url: repoUrl }));
107
+ // Resolve the target namespace. If it can't be pinned down and there's a real choice, fall
108
+ // back to the form (rendered as a picker in the widget) rather than a dead-end ask.
109
+ const skeleton = await context.org.getSkeleton();
110
+ let namespace = args.namespace_id
111
+ ? skeleton.namespaces.find((candidate) => candidate.id === args.namespace_id)
112
+ : undefined;
113
+ if (!namespace && args.namespace) {
114
+ const query = args.namespace.toLowerCase();
115
+ const matches = skeleton.namespaces.filter((candidate) => candidate.name.toLowerCase().includes(query));
116
+ if (matches.length === 1)
117
+ namespace = matches[0];
118
+ else if (matches.length === 0) {
119
+ return fail(translate("createApp.noNamespaceMatch", {
120
+ namespace: args.namespace,
121
+ names: skeleton.namespaces.map((candidate) => candidate.name).join(", "),
122
+ }));
123
+ }
124
+ }
125
+ if (!namespace && skeleton.namespaces.length === 1)
126
+ namespace = skeleton.namespaces[0];
127
+ if (!namespace) {
128
+ if (skeleton.namespaces.length === 0)
129
+ return fail(translate("createApp.noNamespaces"));
130
+ return buildFormReply(context, { name, namespace_id: null, repository_url: repoUrl });
131
+ }
132
+ let created;
133
+ try {
134
+ created = await createApplication(context.np, {
135
+ name,
136
+ namespace_id: namespace.id,
137
+ repository_url: repoUrl,
138
+ ...(args.template_id !== undefined ? { template_id: args.template_id } : {}),
139
+ ...(args.repository_app_path
140
+ ? { repository_app_path: args.repository_app_path, is_mono_repo: true }
141
+ : {}),
142
+ });
143
+ }
144
+ catch (caught) {
145
+ // Convergent under retries: a retried create can hit "already exists". Resolve the app
146
+ // that's already there (by repo, then by exact name+namespace) and return it as reused
147
+ // rather than surfacing a raw 400.
148
+ const existing = (await context.org.inferAppFromRepo(repoUrl).catch(() => undefined)) ??
149
+ (await context.org.findApps({ query: name, namespace: namespace.name }).catch(() => [])).find((candidate) => candidate.name.toLowerCase() === name.toLowerCase() && candidate.namespace_id === namespace.id);
150
+ if (!existing)
151
+ throw caught;
152
+ const orgSlug = await context.org.organizationSlug();
153
+ return reply(translate("createApp.alreadyLinked", { name: existing.name, id: existing.id, repo: repoUrl }) +
154
+ next(translate("createApp.createdHint")) +
155
+ linkLine(translate("md.dashboard"), dashboardLink(orgSlug, existing.nrn)), {
156
+ application: {
157
+ id: existing.id,
158
+ name: existing.name,
159
+ status: existing.status,
160
+ nrn: existing.nrn,
161
+ },
162
+ reused: true,
163
+ });
164
+ }
165
+ // Provisioning is async (pending -> active); wait briefly so the common case answers "ready".
166
+ let app = created;
167
+ for (let attempt = 0; attempt < 8 && app.status === "pending"; attempt++) {
168
+ await sleep(delays.appPoll);
169
+ app = await getApplication(context.np, created.id).catch(() => app);
170
+ }
171
+ const orgSlug = await context.org.organizationSlug();
172
+ const dashboard = linkLine(translate("md.dashboard"), dashboardLink(orgSlug, app.nrn));
173
+ const recentMessages = (app.messages ?? [])
174
+ .slice(-3)
175
+ .map((entry) => `- ${entry.message ?? JSON.stringify(entry)}`)
176
+ .join("\n");
177
+ const structured = { application: { id: app.id, name, status: app.status, nrn: app.nrn } };
178
+ if (app.status === "active") {
179
+ return reply(`${glyph("active")} ${translate("createApp.created", { name, id: app.id, namespace: namespace.name, repo: repoUrl })}${recentMessages ? `\n${recentMessages}` : ""}` +
180
+ next(translate("createApp.createdHint")) +
181
+ dashboard, structured);
182
+ }
183
+ return reply(`${translate("createApp.pending", { name, id: app.id, status: app.status })}${recentMessages ? `\n${recentMessages}` : ""}` +
184
+ next(translate("createApp.pendingHint", { id: app.id })) +
185
+ dashboard, structured);
186
+ },
187
+ });
@@ -0,0 +1,52 @@
1
+ import { z } from "zod";
2
+ import { translate } from "../i18n.js";
3
+ import { ago, glyph, next, shortCommit } from "../md.js";
4
+ import { createRelease, findActiveReleaseForBuild, listBuilds } from "../np/journey.js";
5
+ import { defineTool, fail, reply } from "../tool.js";
6
+ import { TOOL } from "../tool-names.js";
7
+ import { appArg, requireApp } from "./shared.js";
8
+ export const createReleaseTool = defineTool({
9
+ name: TOOL.applicationReleaseCreate,
10
+ title: "Create release",
11
+ description: "Cut an active release from a build (default: the latest successful build; semver auto-bumps the patch). Usually you can just call deploy, which does this for you.",
12
+ annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true },
13
+ errorKey: "createRelease.errorLabel",
14
+ inputSchema: {
15
+ app: appArg,
16
+ build_id: z.number().optional().describe("Build to release (default: latest successful)"),
17
+ semver: z.string().optional().describe('Version like "1.4.2" (default: latest release bumped)'),
18
+ },
19
+ async handler(args, context) {
20
+ const resolved = await requireApp(context, args);
21
+ if ("out" in resolved)
22
+ return resolved.out;
23
+ const app = resolved.app;
24
+ let buildId = args.build_id;
25
+ let buildNote = "";
26
+ if (!buildId) {
27
+ const builds = await listBuilds(context.np, app.id, 5);
28
+ const successful = builds.find((build) => build.status === "successful");
29
+ if (!successful)
30
+ return fail(translate("createRelease.noBuilds", { app: app.name }));
31
+ buildId = successful.id;
32
+ buildNote = translate("createRelease.from", {
33
+ build: successful.id,
34
+ branch: successful.branch ?? "?",
35
+ commit: shortCommit(successful.commit),
36
+ when: ago(successful.created_at),
37
+ });
38
+ }
39
+ // Convergent under retries: if an active release was already cut from this build
40
+ // (matching the requested semver, if any), return it instead of minting a duplicate.
41
+ const existing = await findActiveReleaseForBuild(context.np, app.id, buildId, args.semver);
42
+ if (existing) {
43
+ return reply(`${glyph("active")} ${translate("createRelease.exists", { semver: existing.semver, build: buildId })}${next(translate("createRelease.deployHint", { id: existing.id }))}`, { release: existing, reused: true });
44
+ }
45
+ const release = await createRelease(context.np, {
46
+ application_id: app.id,
47
+ build_id: buildId,
48
+ semver: args.semver,
49
+ });
50
+ return reply(`${glyph("active")} ${translate("createRelease.done", { semver: release.semver, from: buildNote })}${next(translate("createRelease.deployHint", { id: release.id }))}`, { release, reused: false });
51
+ },
52
+ });
@@ -0,0 +1,82 @@
1
+ import { z } from "zod";
2
+ import { translate } from "../i18n.js";
3
+ import { dashboardLink, linkLine, next, table } from "../md.js";
4
+ import { createScope, listScopes, listScopeTypes } from "../np/journey.js";
5
+ import { defineTool, fail, reply } from "../tool.js";
6
+ import { TOOL } from "../tool-names.js";
7
+ import { appArg, dimsLabel, requireApp } from "./shared.js";
8
+ export const createScopeTool = defineTool({
9
+ name: TOOL.applicationScopeCreate,
10
+ title: "Create scope",
11
+ description: "Create a scope (a deploy target, e.g. dev/staging/main) for an application. This provisions real infrastructure asynchronously. If the org has several scope types and none is given, it lists them to pick from.",
12
+ annotations: { destructiveHint: false, openWorldHint: true },
13
+ errorKey: "createScope.errorLabel",
14
+ inputSchema: {
15
+ app: appArg,
16
+ name: z.string().describe('Scope name, e.g. "dev" or "main"'),
17
+ type: z.string().optional().describe("Scope type (e.g. web_pool, serverless, or a custom type)"),
18
+ provider: z.string().optional().describe("Provider for custom scope types"),
19
+ dimensions: z
20
+ .record(z.string())
21
+ .optional()
22
+ .describe('Runtime dimensions, e.g. {"environment":"development","country":"argentina"} — defaults to the shape of an existing scope when the org uses dimensions'),
23
+ },
24
+ async handler(args, context) {
25
+ const resolved = await requireApp(context, args);
26
+ if ("out" in resolved)
27
+ return resolved.out;
28
+ const app = resolved.app;
29
+ // Convergent under retries: a scope with this name already exists -> return it, don't
30
+ // provision a duplicate (scope name is the identifier within an application).
31
+ const siblings = await listScopes(context.np, app.id);
32
+ const existing = siblings.find((scope) => scope.name.toLowerCase() === args.name.toLowerCase());
33
+ if (existing) {
34
+ const orgSlug = await context.org.organizationSlug();
35
+ return reply(translate("createScope.exists", { name: existing.name, id: existing.id, status: existing.status }) +
36
+ next(translate("createScope.provisioningHint", { name: existing.name })) +
37
+ linkLine(translate("md.dashboard"), dashboardLink(orgSlug, existing.nrn)), { scope: existing, reused: true });
38
+ }
39
+ let type = args.type;
40
+ let provider = args.provider;
41
+ if (!type && app.nrn) {
42
+ const scopeTypes = await listScopeTypes(context.np, app.nrn);
43
+ const onlyType = scopeTypes.length === 1 ? scopeTypes[0] : undefined;
44
+ if (onlyType) {
45
+ type = onlyType.type ?? onlyType.name;
46
+ provider = provider ?? onlyType.provider ?? undefined;
47
+ }
48
+ else if (scopeTypes.length > 1) {
49
+ return reply(`${translate("createScope.whichType", { name: args.name })}\n\n${table([translate("header.type"), translate("header.name"), translate("header.provider")], scopeTypes.map((scopeType) => [
50
+ scopeType.type ?? "",
51
+ scopeType.name ?? "",
52
+ scopeType.provider ?? "",
53
+ ]))}${next(translate("createScope.typeHint", { name: args.name }))}`, { scope_types: scopeTypes });
54
+ }
55
+ }
56
+ if (!type)
57
+ return fail(translate("createScope.noTypes"));
58
+ // Orgs with runtime dimensions usually require them — borrow the shape from a sibling scope.
59
+ let dimensions = args.dimensions;
60
+ if (!dimensions) {
61
+ const donor = siblings.find((sibling) => sibling.dimensions && Object.keys(sibling.dimensions).length);
62
+ if (donor)
63
+ dimensions = donor.dimensions;
64
+ }
65
+ const scope = await createScope(context.np, {
66
+ application_id: app.id,
67
+ name: args.name,
68
+ type,
69
+ provider,
70
+ dimensions,
71
+ });
72
+ const orgSlug = await context.org.organizationSlug();
73
+ return reply(translate("createScope.provisioning", {
74
+ name: scope.name,
75
+ id: scope.id,
76
+ type,
77
+ dimensions: dimensions ? `, ${dimsLabel(dimensions)}` : "",
78
+ }) +
79
+ next(translate("createScope.provisioningHint", { name: scope.name })) +
80
+ linkLine(translate("md.dashboard"), dashboardLink(orgSlug, scope.nrn)), { scope });
81
+ },
82
+ });
@@ -0,0 +1,178 @@
1
+ import { z } from "zod";
2
+ import { translate } from "../i18n.js";
3
+ import { dashboardLink, next, shortCommit, table } from "../md.js";
4
+ import { createDeployment, createRelease, getDeployment, getRelease, isDeploymentTerminal, listAssets, listBuilds, listReleases, listScopeDeployments, listScopes, listScopeTypes, pickAsset, } from "../np/journey.js";
5
+ import { renderRollout } from "../render.js";
6
+ import { defineTool, fail, reply } from "../tool.js";
7
+ import { TOOL } from "../tool-names.js";
8
+ import { appArg, delays, pickScope, requireApp, sleep } from "./shared.js";
9
+ const sameVersion = (left, right) => left.replace(/^v/, "") === right.replace(/^v/, "");
10
+ /** What ships: exact release > named version (existing first) > explicit build > newest build > newest release. */
11
+ async function resolveRelease(context, applicationId, args) {
12
+ const version = args.version ?? args.semver;
13
+ // Wide window: a named version (e.g. a rollback target) may be many releases old. If it
14
+ // existed but fell outside this window we would wrongly treat it as new and cut latest
15
+ // code under its name — so search deep enough that only pathologically active apps miss.
16
+ const [releases, builds] = await Promise.all([
17
+ listReleases(context.np, applicationId, { status: "active", limit: 200 }),
18
+ listBuilds(context.np, applicationId, 5),
19
+ ]);
20
+ const successfulBuild = builds.find((build) => build.status === "successful");
21
+ if (args.release_id) {
22
+ const releaseId = args.release_id;
23
+ const release = releases.find((candidate) => candidate.id === releaseId) ??
24
+ (await getRelease(context.np, releaseId).catch(() => ({
25
+ id: releaseId,
26
+ semver: `#${releaseId}`,
27
+ status: "active",
28
+ })));
29
+ return { release, note: "" };
30
+ }
31
+ if (version) {
32
+ const existing = releases.find((candidate) => sameVersion(candidate.semver, version));
33
+ if (existing) {
34
+ return {
35
+ release: existing,
36
+ note: translate("deploy.usingRelease", { semver: existing.semver, id: existing.id }),
37
+ };
38
+ }
39
+ }
40
+ if (args.build_id) {
41
+ const release = await createRelease(context.np, {
42
+ application_id: applicationId,
43
+ build_id: args.build_id,
44
+ semver: version,
45
+ });
46
+ return {
47
+ release,
48
+ note: translate("deploy.createdRelease", { semver: release.semver, build: args.build_id }),
49
+ };
50
+ }
51
+ if (version && !successfulBuild) {
52
+ const known = releases.length
53
+ ? translate("deploy.knownVersions", {
54
+ versions: releases
55
+ .slice(0, 8)
56
+ .map((candidate) => candidate.semver)
57
+ .join(", "),
58
+ })
59
+ : "";
60
+ return { out: fail(translate("deploy.noSuchVersion", { version, known })) };
61
+ }
62
+ if (successfulBuild && (version || !releases[0] || releases[0].build_id !== successfulBuild.id)) {
63
+ const release = await createRelease(context.np, {
64
+ application_id: applicationId,
65
+ build_id: successfulBuild.id,
66
+ semver: version,
67
+ });
68
+ return {
69
+ release,
70
+ note: translate("deploy.createdReleaseFrom", {
71
+ semver: release.semver,
72
+ build: successfulBuild.id,
73
+ branch: successfulBuild.branch ?? "?",
74
+ commit: shortCommit(successfulBuild.commit),
75
+ }),
76
+ };
77
+ }
78
+ if (releases[0])
79
+ return { release: releases[0], note: "" };
80
+ return { out: fail(translate("deploy.nothing") + next(translate("deploy.nothingHint"))) };
81
+ }
82
+ export const deployTool = defineTool({
83
+ name: TOOL.applicationDeploymentCreate,
84
+ title: "Deploy",
85
+ description: 'Ship an application: deploys the latest code with sensible defaults — uses the latest successful build, creates the release for you (semver auto-bump) if needed, targets the app\'s only scope (or scope:"name"). Returns the live rollout view. For an app with traffic, follow with the traffic tool to move traffic over.',
86
+ annotations: { destructiveHint: false, openWorldHint: true },
87
+ widget: "np-panel",
88
+ errorKey: "deploy.errorLabel",
89
+ // The platform reports a missing/incompatible asset choice as a cross-app mismatch.
90
+ onError: (message) => /different applications/i.test(message) ? fail(translate("deploy.rejected", { message })) : undefined,
91
+ inputSchema: {
92
+ app: appArg,
93
+ scope: z
94
+ .string()
95
+ .optional()
96
+ .describe('Scope by name or dimension — "dev", "production", or "environment=production" (default: the only scope)'),
97
+ release_id: z.number().optional().describe("Deploy this exact release"),
98
+ version: z
99
+ .string()
100
+ .optional()
101
+ .describe('Version to ship, e.g. "0.0.1" — uses the existing release with that semver (rollforward/rollback to a version), or names the newly cut release if none exists'),
102
+ build_id: z.number().optional().describe("Cut a release from this build, then deploy it"),
103
+ semver: z.string().optional().describe("Deprecated alias of version"),
104
+ asset: z
105
+ .string()
106
+ .optional()
107
+ .describe("Asset name when the build has several (auto-picked by scope type otherwise)"),
108
+ },
109
+ async handler(args, context) {
110
+ const resolved = await requireApp(context, args);
111
+ if ("out" in resolved)
112
+ return resolved.out;
113
+ const app = resolved.app;
114
+ const scopes = await listScopes(context.np, app.id);
115
+ const picked = pickScope(scopes, args.scope);
116
+ if ("out" in picked) {
117
+ if (scopes.length === 0 && app.nrn) {
118
+ const scopeTypes = await listScopeTypes(context.np, app.nrn);
119
+ if (scopeTypes.length) {
120
+ const firstType = scopeTypes[0]?.type ?? scopeTypes[0]?.name ?? "";
121
+ const markdown = translate("deploy.noScopeTypes", {
122
+ types: scopeTypes.map((scopeType) => `**${scopeType.name}**`).join(", "),
123
+ }) + next(translate("deploy.createScopeHint", { type: firstType }));
124
+ return fail(markdown, { scope_types: scopeTypes });
125
+ }
126
+ }
127
+ return picked.out;
128
+ }
129
+ const scope = picked.scope;
130
+ const shipping = await resolveRelease(context, app.id, args);
131
+ if ("out" in shipping)
132
+ return shipping.out;
133
+ const { release, note } = shipping;
134
+ // Multi-asset builds must name the asset to ship; the platform's error otherwise is misleading.
135
+ let assetName = args.asset;
136
+ if (!assetName) {
137
+ const buildId = release.build_id ?? (await getRelease(context.np, release.id).catch(() => release)).build_id;
138
+ if (buildId) {
139
+ const assets = await listAssets(context.np, buildId);
140
+ const choice = pickAsset(assets, scope.type);
141
+ if ("ambiguous" in choice) {
142
+ return reply(`${translate("deploy.multiAsset", { build: buildId, scope: scope.name, type: scope.type ?? "?" })}\n\n${table([translate("header.asset"), translate("header.type"), translate("header.platform")], choice.ambiguous.map((asset) => [asset.name, asset.type, asset.platform ?? ""]))}${next(translate("deploy.assetHint", { name: choice.ambiguous[0]?.name ?? "" }))}`, { assets: choice.ambiguous });
143
+ }
144
+ assetName = choice.name;
145
+ }
146
+ }
147
+ const orgSlug = await context.org.organizationSlug();
148
+ // Convergent under retries: if this exact release is already rolling out on this scope
149
+ // (a non-terminal deployment), return that rollout instead of starting a second one.
150
+ const recent = await listScopeDeployments(context.np, scope.id, 5);
151
+ const inFlight = recent.find((candidate) => candidate.release_id === release.id && !isDeploymentTerminal(candidate.status));
152
+ if (inFlight) {
153
+ const { md, structured } = renderRollout({
154
+ deployment: inFlight,
155
+ scope,
156
+ release,
157
+ dashboard: dashboardLink(orgSlug, scope.nrn),
158
+ title: translate("render.deploying"),
159
+ });
160
+ return reply(`${translate("deploy.alreadyRolling", { scope: scope.name })}\n\n${md}`, structured);
161
+ }
162
+ let deployment = await createDeployment(context.np, {
163
+ scope_id: scope.id,
164
+ release_id: release.id,
165
+ asset_name: assetName,
166
+ });
167
+ await sleep(delays.afterDeploy); // one quick refresh so the answer shows real progress
168
+ deployment = await getDeployment(context.np, deployment.id).catch(() => deployment);
169
+ const { md, structured } = renderRollout({
170
+ deployment,
171
+ scope,
172
+ release,
173
+ dashboard: dashboardLink(orgSlug, scope.nrn),
174
+ title: translate("render.deploying"),
175
+ });
176
+ return reply(`${note}${note ? "\n\n" : ""}${md}`, structured);
177
+ },
178
+ });
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ import { plural, translate } from "../i18n.js";
3
+ import { next } from "../md.js";
4
+ import { DEFAULT_APP_LIMIT } from "../np/context.js";
5
+ import { renderAppsTable } from "../render.js";
6
+ import { defineTool, reply } from "../tool.js";
7
+ import { TOOL } from "../tool-names.js";
8
+ export const findAppsTool = defineTool({
9
+ name: TOOL.applicationList,
10
+ title: "Find applications",
11
+ description: "Search applications across the whole organization by partial name (and optionally namespace). Fast (parallel + cached). Use when the repo isn't linked or the user names an app you don't know.",
12
+ annotations: { readOnlyHint: true, openWorldHint: true },
13
+ widget: "find-apps",
14
+ errorKey: "findApps.errorLabel",
15
+ inputSchema: {
16
+ query: z.string().optional().describe("Partial app name (case-insensitive). Omit to list everything."),
17
+ namespace: z.string().optional().describe("Limit to namespaces whose name contains this"),
18
+ limit: z.number().optional(),
19
+ },
20
+ async handler(args, context) {
21
+ const apps = await context.org.findApps(args);
22
+ if (apps.length === 0) {
23
+ return reply(translate("findApps.noneMatching", {
24
+ matching: args.query ? translate("findApps.matching", { query: args.query }) : "",
25
+ inNamespace: args.namespace ? translate("findApps.inNamespace", { namespace: args.namespace }) : "",
26
+ }) + next(translate("findApps.createHint")), { count: 0, apps: [] });
27
+ }
28
+ const limit = args.limit ?? DEFAULT_APP_LIMIT;
29
+ const truncated = apps.length >= limit;
30
+ const truncationNote = truncated
31
+ ? `\n\n_${translate("findApps.truncated", { shown: apps.length })}_`
32
+ : "";
33
+ const markdown = `${plural(apps.length, "findApps.count.one", "findApps.count.many")}${truncationNote}\n\n${renderAppsTable(apps)}${next(translate("findApps.statusHint"))}`;
34
+ return reply(markdown, { count: apps.length, apps, truncated });
35
+ },
36
+ });