@nullplatform/mcp 0.1.13 → 0.1.15
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.
- package/README.md +10 -0
- package/dist/http.js +16 -12
- package/dist/i18n.js +6 -0
- package/dist/log.js +53 -0
- package/dist/np/client.js +10 -1
- package/dist/np/context.js +30 -21
- package/dist/np/journey.js +14 -4
- package/dist/render.js +12 -13
- package/dist/surfaces/developer.js +21 -4
- package/dist/tool.js +64 -6
- package/dist/tools/approvals.js +1 -1
- package/dist/tools/builds.js +1 -1
- package/dist/tools/create-app.js +158 -132
- package/dist/tools/create-link.js +82 -55
- package/dist/tools/create-release.js +1 -1
- package/dist/tools/create-scope.js +35 -23
- package/dist/tools/create-service.js +39 -25
- package/dist/tools/delete-link.js +1 -1
- package/dist/tools/delete-param.js +1 -1
- package/dist/tools/delete-service.js +1 -1
- package/dist/tools/deploy.js +52 -30
- package/dist/tools/deployments.js +1 -1
- package/dist/tools/entity-get.js +1 -1
- package/dist/tools/entity-list.js +14 -6
- package/dist/tools/find-apps.js +5 -2
- package/dist/tools/logs.js +114 -17
- package/dist/tools/metrics.js +1 -1
- package/dist/tools/overview.js +1 -1
- package/dist/tools/params.js +8 -2
- package/dist/tools/playbook.js +1 -1
- package/dist/tools/releases.js +1 -1
- package/dist/tools/services.js +1 -1
- package/dist/tools/set-params.js +18 -19
- package/dist/tools/shared.js +52 -0
- package/dist/tools/status.js +4 -4
- package/dist/tools/traffic.js +38 -26
- package/dist/tools/update-link.js +1 -1
- package/dist/tools/update-service.js +1 -1
- package/dist/ui.js +1 -1
- package/package.json +5 -1
- package/widgets-dist/approvals.html +120 -10
- package/widgets-dist/builds.html +127 -17
- package/widgets-dist/create-app.html +129 -19
- package/widgets-dist/deployments.html +132 -22
- package/widgets-dist/find-apps.html +131 -21
- package/widgets-dist/logs.html +133 -23
- package/widgets-dist/manifest.json +16 -16
- package/widgets-dist/metrics.html +120 -10
- package/widgets-dist/overview.html +136 -26
- package/widgets-dist/params.html +121 -11
- package/widgets-dist/releases.html +136 -26
- package/widgets-dist/service-action.html +124 -14
- package/widgets-dist/service-create.html +123 -13
- package/widgets-dist/service-delete.html +120 -10
- package/widgets-dist/service-link.html +123 -13
- package/widgets-dist/services.html +120 -10
- package/widgets-dist/{np-panel.html → status.html} +135 -25
package/dist/tools/create-app.js
CHANGED
|
@@ -4,24 +4,24 @@ import { dashboardLink, glyph, linkLine, next } from "../md.js";
|
|
|
4
4
|
import { createApplication, getApplication, listTemplates } from "../np/journey.js";
|
|
5
5
|
import { defineTool, fail, reply } from "../tool.js";
|
|
6
6
|
import { TOOL } from "../tool-names.js";
|
|
7
|
-
import { delays, httpsRepoUrl, sleep } from "./shared.js";
|
|
8
|
-
function pickNamespace(namespaces, hints) {
|
|
7
|
+
import { delays, httpsRepoUrl, resolveChoice, sleep } from "./shared.js";
|
|
8
|
+
export function pickNamespace(namespaces, hints) {
|
|
9
|
+
// Namespace is the generic resolveChoice with one extra twist — account narrowing. An explicit id is
|
|
10
|
+
// unambiguous, so honour it against the FULL list FIRST (the account filter only disambiguates a
|
|
11
|
+
// repeated NAME; it must never veto a real id). Then resolve the name within the named account, and
|
|
12
|
+
// fall back to all namespaces so a correct name still pins even when the account label doesn't line up.
|
|
13
|
+
const byId = resolveChoice(namespaces, { id: hints.namespace_id });
|
|
14
|
+
if (byId)
|
|
15
|
+
return byId;
|
|
9
16
|
const accountQuery = hints.account?.toLowerCase();
|
|
10
|
-
const
|
|
17
|
+
const inAccount = accountQuery
|
|
11
18
|
? namespaces.filter((candidate) => (candidate.account_name ?? "").toLowerCase().includes(accountQuery))
|
|
12
19
|
: namespaces;
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
if (hints.namespace) {
|
|
20
|
-
const query = hints.namespace.toLowerCase();
|
|
21
|
-
const matches = candidates.filter((candidate) => candidate.name.toLowerCase().includes(query));
|
|
22
|
-
if (matches.length === 1)
|
|
23
|
-
return matches[0];
|
|
24
|
-
}
|
|
20
|
+
const named = resolveChoice(inAccount.length ? inAccount : namespaces, { text: hints.namespace }) ??
|
|
21
|
+
resolveChoice(namespaces, { text: hints.namespace });
|
|
22
|
+
if (named)
|
|
23
|
+
return named;
|
|
24
|
+
const candidates = inAccount.length ? inAccount : namespaces;
|
|
25
25
|
if (candidates.length === 1)
|
|
26
26
|
return candidates[0];
|
|
27
27
|
return undefined;
|
|
@@ -41,11 +41,12 @@ async function buildFormReply(context, prefill) {
|
|
|
41
41
|
// of the global set. Fetch only once a namespace is pinned (the widget refetches per namespace on
|
|
42
42
|
// change); the org-level NRN returns a misleading near-empty subset.
|
|
43
43
|
const templates = picked ? await listTemplates(context.np, picked.nrn).catch(() => []) : [];
|
|
44
|
-
// Same
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
// Same generic rule as the namespace: resolve the MODEL's stack hint (`template`) — or an explicit
|
|
45
|
+
// template_id — against THIS namespace's templates, matching on name + tags, and pin the only one.
|
|
46
|
+
// Inference is the model's job (it holds the context); the tool only resolves the hint it passes —
|
|
47
|
+
// no keyword guessing here. Leave it null on a genuine unhinted/ambiguous choice (a wrong stack is
|
|
48
|
+
// worse than a deliberate pick).
|
|
49
|
+
const templateId = resolveChoice(templates, { id: prefill.template_id, text: prefill.template }, { pinOnly: true, haystack: (template) => `${template.name} ${(template.tags ?? []).join(" ")}` })?.id ?? null;
|
|
49
50
|
const namespaces = skeleton.namespaces.map((namespace) => ({
|
|
50
51
|
id: namespace.id,
|
|
51
52
|
name: namespace.name,
|
|
@@ -56,6 +57,9 @@ async function buildFormReply(context, prefill) {
|
|
|
56
57
|
namespaces: namespaces.map((namespace) => namespace.name).join(", ") || "—",
|
|
57
58
|
}), {
|
|
58
59
|
mode: "form",
|
|
60
|
+
// Model-facing note (not rendered to the user): the form is already open with every template
|
|
61
|
+
// loaded in its dropdown, so the model must NOT re-call to "pre-select" a template it just saw.
|
|
62
|
+
note: "This create FORM is now OPEN with all of this namespace's templates loaded in its Template dropdown. Do NOT call application_create again to pre-select a template or refine the namespace you just learned about — re-opening renders a DUPLICATE form panel for the same app (the mistake to avoid). To steer the choice: either name the recommended template in your text reply for the user to pick in this form's dropdown, OR (better, next time) pass a `template` stack hint on the FIRST call so it pre-selects in one render. The user picks/confirms in THIS form.",
|
|
59
63
|
namespaces,
|
|
60
64
|
templates: templates.map((template) => ({
|
|
61
65
|
id: template.id,
|
|
@@ -72,10 +76,109 @@ async function buildFormReply(context, prefill) {
|
|
|
72
76
|
},
|
|
73
77
|
});
|
|
74
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Resolve where a new app lives — the (account, namespace) pair — from the create args. Mirrors
|
|
81
|
+
* requireApp's shape: returns the pinned namespace, or an `out` reply — a `fail` when a named
|
|
82
|
+
* account/namespace matches nothing, or the create FORM when it's a real choice that needs the
|
|
83
|
+
* picker rather than a dead-end ask. Narrows to the named account first (a namespace name can
|
|
84
|
+
* repeat across accounts), then matches by id/name and auto-picks the sole candidate.
|
|
85
|
+
*/
|
|
86
|
+
async function resolveCreateNamespace(context, args, name, repoUrl) {
|
|
87
|
+
const skeleton = await context.org.getSkeleton();
|
|
88
|
+
const accountQuery = args.account?.toLowerCase();
|
|
89
|
+
const candidates = accountQuery
|
|
90
|
+
? skeleton.namespaces.filter((candidate) => (candidate.account_name ?? "").toLowerCase().includes(accountQuery))
|
|
91
|
+
: skeleton.namespaces;
|
|
92
|
+
if (accountQuery && candidates.length === 0) {
|
|
93
|
+
const accounts = [
|
|
94
|
+
...new Set(skeleton.namespaces.map((candidate) => candidate.account_name).filter(Boolean)),
|
|
95
|
+
];
|
|
96
|
+
return {
|
|
97
|
+
out: fail(translate("createApp.noAccountMatch", { account: args.account ?? "", names: accounts.join(", ") })),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
let namespace = args.namespace_id
|
|
101
|
+
? candidates.find((candidate) => candidate.id === args.namespace_id)
|
|
102
|
+
: undefined;
|
|
103
|
+
if (!namespace && args.namespace) {
|
|
104
|
+
const query = args.namespace.toLowerCase();
|
|
105
|
+
const matches = candidates.filter((candidate) => candidate.name.toLowerCase().includes(query));
|
|
106
|
+
if (matches.length === 1)
|
|
107
|
+
namespace = matches[0];
|
|
108
|
+
else if (matches.length === 0) {
|
|
109
|
+
return {
|
|
110
|
+
out: fail(translate("createApp.noNamespaceMatch", {
|
|
111
|
+
namespace: args.namespace,
|
|
112
|
+
names: candidates.map((candidate) => candidate.name).join(", "),
|
|
113
|
+
})),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// One namespace in scope (the whole org, or the chosen account) → auto-pick it.
|
|
118
|
+
if (!namespace && candidates.length === 1)
|
|
119
|
+
namespace = candidates[0];
|
|
120
|
+
if (!namespace) {
|
|
121
|
+
if (skeleton.namespaces.length === 0)
|
|
122
|
+
return { out: fail(translate("createApp.noNamespaces")) };
|
|
123
|
+
return {
|
|
124
|
+
out: await buildFormReply(context, {
|
|
125
|
+
name,
|
|
126
|
+
account: args.account,
|
|
127
|
+
namespace: args.namespace,
|
|
128
|
+
template: args.template,
|
|
129
|
+
template_id: args.template_id,
|
|
130
|
+
repository_url: repoUrl,
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { namespace };
|
|
135
|
+
}
|
|
136
|
+
/** The convergent "this repo is already an app" reply — shared by the pre-create check and the
|
|
137
|
+
* create-conflict recovery (a retried create that hit "already exists"), so both read identically. */
|
|
138
|
+
async function reusedAppReply(context, app, repoUrl) {
|
|
139
|
+
const orgSlug = await context.org.organizationSlug();
|
|
140
|
+
return reply(translate("createApp.alreadyLinked", { name: app.name, id: app.id, repo: repoUrl }) +
|
|
141
|
+
next(translate("createApp.createdHint")) +
|
|
142
|
+
linkLine(translate("md.dashboard"), dashboardLink(orgSlug, app.nrn)), {
|
|
143
|
+
application: { id: app.id, name: app.name, status: app.status, nrn: app.nrn },
|
|
144
|
+
reused: true,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** Poll a freshly-created app briefly (pending → active), then build its reply — ready vs still-
|
|
148
|
+
* provisioning, with the next-step hint (a scaffolded NEW repo must be cloned before building). */
|
|
149
|
+
async function provisioningReply(context, created, details) {
|
|
150
|
+
let app = created;
|
|
151
|
+
for (let attempt = 0; attempt < 8 && app.status === "pending"; attempt++) {
|
|
152
|
+
await sleep(delays.appPoll);
|
|
153
|
+
app = await getApplication(context.np, created.id).catch(() => app);
|
|
154
|
+
}
|
|
155
|
+
const orgSlug = await context.org.organizationSlug();
|
|
156
|
+
const dashboard = linkLine(translate("md.dashboard"), dashboardLink(orgSlug, app.nrn));
|
|
157
|
+
const recentMessages = (app.messages ?? [])
|
|
158
|
+
.slice(-3)
|
|
159
|
+
.map((entry) => `- ${entry.message ?? JSON.stringify(entry)}`)
|
|
160
|
+
.join("\n");
|
|
161
|
+
const structured = {
|
|
162
|
+
application: { id: app.id, name: details.name, status: app.status, nrn: app.nrn },
|
|
163
|
+
scaffolded: details.scaffolded,
|
|
164
|
+
repository_url: details.repoUrl,
|
|
165
|
+
};
|
|
166
|
+
const createdHint = details.scaffolded
|
|
167
|
+
? translate("createApp.createdHintNew", { repo: details.repoUrl })
|
|
168
|
+
: translate("createApp.createdHint");
|
|
169
|
+
if (app.status === "active") {
|
|
170
|
+
return reply(`${glyph("active")} ${translate("createApp.created", { name: details.name, id: app.id, namespace: details.namespaceName, repo: details.repoUrl })}${recentMessages ? `\n${recentMessages}` : ""}` +
|
|
171
|
+
next(createdHint) +
|
|
172
|
+
dashboard, structured);
|
|
173
|
+
}
|
|
174
|
+
return reply(`${translate("createApp.pending", { name: details.name, id: app.id, status: app.status })}${recentMessages ? `\n${recentMessages}` : ""}` +
|
|
175
|
+
next(translate("createApp.pendingHint", { id: app.id })) +
|
|
176
|
+
dashboard, structured);
|
|
177
|
+
}
|
|
75
178
|
export const createAppTool = defineTool({
|
|
76
179
|
name: TOOL.applicationCreate,
|
|
77
180
|
title: "Create application",
|
|
78
|
-
description:
|
|
181
|
+
description: 'Create a nullplatform application. THIS IS THE ONE AND ONLY WAY to create/scaffold/set-up/bootstrap a new app, and it is the PLATFORM\'s job: nullplatform itself creates the git repository (on whatever provider the account is configured for — GitHub, GitLab, Bitbucket, …) from a template and provisions it. So for ANY "create / scaffold / set up / build a new app" request, route it HERE — do NOT brainstorm or design the app first, do NOT run an implementation process, do NOT `git init` or write files locally, and do NOT offer a "scaffold locally first" choice or ask "where should it live / how do we deploy": there is no local path, so that is not a real question. Infer the stack and pass it as `template` ON THE FIRST CALL — a SEMANTIC word ("frontend", "react", "go", "python", "api") is enough; the tool resolves it to the namespace\'s real templates and pre-selects the best fit, so do NOT open the form with the template blank and then recommend one in text. Never ask an open "what stack / what scope" brainstorm question. GATE FIRST: if the request is bare and gives you NOTHING to infer from (e.g. just "create an app" / "create a new application" with no hint of what it\'s for or what to name it), DO NOT call this tool yet — first ask the user, in chat, what the app is for AND what to name it, and WAIT for both; call this only once you have the purpose (to pre-fill the namespace) AND the name in hand. Do NOT call it in the meantime, while still waiting on the name — opening a blank or half-filled form and continuing to ask is a failure, not a fallback. When you DO have context, INFER the best-fitting namespace and account from what the app is FOR (a demo → a demos/examples namespace, a backend service → a services namespace) and pass them as `namespace`/`account` so the form opens pre-selected on your inference — the user can still change it. Calling without `provision:true` opens an interactive form and creates nothing — the user confirms the 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 itself submits with provision:true to actually create. Only the form\'s submit sets provision:true; you (the model) NEVER set it and NEVER pass a repository_url to "pre-fill" expecting a create — passing a repository_url without provision just opens the form, it does NOT write anything. Provisioning is async — the create waits briefly and reports progress.',
|
|
79
182
|
annotations: { destructiveHint: false, openWorldHint: true },
|
|
80
183
|
widget: "create-app",
|
|
81
184
|
errorKey: "createApp.errorLabel",
|
|
@@ -94,6 +197,10 @@ export const createAppTool = defineTool({
|
|
|
94
197
|
.string()
|
|
95
198
|
.optional()
|
|
96
199
|
.describe("Full git repo URL. New repos: auto-generated from the account + name. Import: the existing URL."),
|
|
200
|
+
template: z
|
|
201
|
+
.string()
|
|
202
|
+
.optional()
|
|
203
|
+
.describe('Stack to scaffold from — ALWAYS pass this on the first call so the form opens with the template ALREADY selected; never open the form with this blank and then recommend a template in text. Two ways to fill it: (1) if a single SEMANTIC word uniquely identifies the stack from the app\'s purpose/name (a Python service → "python", a Go API → "go"), pass that word — the tool matches it to the namespace\'s real templates by name + tags. (2) if a semantic word would be AMBIGUOUS — several templates could match (e.g. multiple frontend kits), or you want the BEST fit for the use case (a bank demo → a bank-themed frontend kit) — you MUST first LIST the templates headlessly with `entity_list entity:"template" parent_type:"namespace" parent_id:<namespace_id>` (NO panel), pick the SPECIFIC one, and pass its name here. Do the entity_list BEFORE opening this form. (prefer template_id only when you already know the exact id.)'),
|
|
97
204
|
template_id: z
|
|
98
205
|
.number()
|
|
99
206
|
.optional()
|
|
@@ -102,44 +209,34 @@ export const createAppTool = defineTool({
|
|
|
102
209
|
.string()
|
|
103
210
|
.optional()
|
|
104
211
|
.describe("Import only: the folder inside a monorepo that holds this application"),
|
|
212
|
+
provision: z
|
|
213
|
+
.boolean()
|
|
214
|
+
.optional()
|
|
215
|
+
.describe("Set true ONLY to actually create the app + repository — this is the form's Create button. Omit it to open/pre-fill the form, even when you pass a repository_url: opening or pre-filling NEVER creates anything. You (the model) must NOT set this; the user confirms by submitting the form."),
|
|
105
216
|
},
|
|
106
217
|
async handler(args, context) {
|
|
107
|
-
// Create ONLY on an explicit
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
218
|
+
// Create ONLY on an explicit provision:true (the form's Create button). The presence of a
|
|
219
|
+
// repository_url is NOT the trigger: the model routinely passes one to PRE-FILL the form, and
|
|
220
|
+
// treating that as "create now" silently provisioned an app the user never confirmed (the bug
|
|
221
|
+
// where the panel jumped straight to "Creating…"). So opening or pre-filling the form — with or
|
|
222
|
+
// without a repository_url — creates nothing; the user submits to create. create_app also does
|
|
223
|
+
// NOT read the ambient git remote, so running the MCP inside some repo never auto-creates either.
|
|
112
224
|
const repoUrl = args.repository_url ? httpsRepoUrl(args.repository_url) : undefined;
|
|
113
|
-
if (!repoUrl) {
|
|
225
|
+
if (!args.provision || !repoUrl) {
|
|
114
226
|
return buildFormReply(context, {
|
|
115
227
|
name: args.name,
|
|
116
228
|
account: args.account,
|
|
117
229
|
namespace: args.namespace,
|
|
118
230
|
namespace_id: args.namespace_id,
|
|
231
|
+
template: args.template,
|
|
119
232
|
template_id: args.template_id,
|
|
120
|
-
repository_url: null,
|
|
233
|
+
repository_url: args.repository_url ?? null,
|
|
121
234
|
});
|
|
122
235
|
}
|
|
123
236
|
// Convergent under retries: a repo already linked to an app returns that app, never a duplicate.
|
|
124
237
|
const alreadyLinked = await context.org.inferAppFromRepo(repoUrl);
|
|
125
|
-
if (alreadyLinked)
|
|
126
|
-
|
|
127
|
-
return reply(translate("createApp.alreadyLinked", {
|
|
128
|
-
name: alreadyLinked.name,
|
|
129
|
-
id: alreadyLinked.id,
|
|
130
|
-
repo: repoUrl,
|
|
131
|
-
}) +
|
|
132
|
-
next(translate("createApp.createdHint")) +
|
|
133
|
-
linkLine(translate("md.dashboard"), dashboardLink(orgSlug, alreadyLinked.nrn)), {
|
|
134
|
-
application: {
|
|
135
|
-
id: alreadyLinked.id,
|
|
136
|
-
name: alreadyLinked.name,
|
|
137
|
-
status: alreadyLinked.status,
|
|
138
|
-
nrn: alreadyLinked.nrn,
|
|
139
|
-
},
|
|
140
|
-
reused: true,
|
|
141
|
-
});
|
|
142
|
-
}
|
|
238
|
+
if (alreadyLinked)
|
|
239
|
+
return reusedAppReply(context, alreadyLinked, repoUrl);
|
|
143
240
|
const name = args.name ??
|
|
144
241
|
repoUrl
|
|
145
242
|
.split("/")
|
|
@@ -147,49 +244,12 @@ export const createAppTool = defineTool({
|
|
|
147
244
|
?.replace(/\.git$/, "");
|
|
148
245
|
if (!name)
|
|
149
246
|
return fail(translate("createApp.noName", { url: repoUrl }));
|
|
150
|
-
// Resolve where the app lives — an (account, namespace) pair.
|
|
151
|
-
// (a
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
const candidates = accountQuery
|
|
157
|
-
? skeleton.namespaces.filter((candidate) => (candidate.account_name ?? "").toLowerCase().includes(accountQuery))
|
|
158
|
-
: skeleton.namespaces;
|
|
159
|
-
if (accountQuery && candidates.length === 0) {
|
|
160
|
-
const accounts = [
|
|
161
|
-
...new Set(skeleton.namespaces.map((candidate) => candidate.account_name).filter(Boolean)),
|
|
162
|
-
];
|
|
163
|
-
return fail(translate("createApp.noAccountMatch", { account: args.account ?? "", names: accounts.join(", ") }));
|
|
164
|
-
}
|
|
165
|
-
let namespace = args.namespace_id
|
|
166
|
-
? candidates.find((candidate) => candidate.id === args.namespace_id)
|
|
167
|
-
: undefined;
|
|
168
|
-
if (!namespace && args.namespace) {
|
|
169
|
-
const query = args.namespace.toLowerCase();
|
|
170
|
-
const matches = candidates.filter((candidate) => candidate.name.toLowerCase().includes(query));
|
|
171
|
-
if (matches.length === 1)
|
|
172
|
-
namespace = matches[0];
|
|
173
|
-
else if (matches.length === 0) {
|
|
174
|
-
return fail(translate("createApp.noNamespaceMatch", {
|
|
175
|
-
namespace: args.namespace,
|
|
176
|
-
names: candidates.map((candidate) => candidate.name).join(", "),
|
|
177
|
-
}));
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
// One namespace in scope (the whole org, or the chosen account) → auto-pick it.
|
|
181
|
-
if (!namespace && candidates.length === 1)
|
|
182
|
-
namespace = candidates[0];
|
|
183
|
-
if (!namespace) {
|
|
184
|
-
if (skeleton.namespaces.length === 0)
|
|
185
|
-
return fail(translate("createApp.noNamespaces"));
|
|
186
|
-
return buildFormReply(context, {
|
|
187
|
-
name,
|
|
188
|
-
account: args.account,
|
|
189
|
-
namespace: args.namespace,
|
|
190
|
-
repository_url: repoUrl,
|
|
191
|
-
});
|
|
192
|
-
}
|
|
247
|
+
// Resolve where the app lives — an (account, namespace) pair. Returns the pinned namespace, or
|
|
248
|
+
// an `out` reply (a fail on no match, or the form picker when it's a real choice) — see the helper.
|
|
249
|
+
const resolvedNamespace = await resolveCreateNamespace(context, args, name, repoUrl);
|
|
250
|
+
if ("out" in resolvedNamespace)
|
|
251
|
+
return resolvedNamespace.out;
|
|
252
|
+
const namespace = resolvedNamespace.namespace;
|
|
193
253
|
let created;
|
|
194
254
|
try {
|
|
195
255
|
created = await createApplication(context.np, {
|
|
@@ -210,50 +270,16 @@ export const createAppTool = defineTool({
|
|
|
210
270
|
(await context.org.findApps({ query: name, namespace: namespace.name }).catch(() => [])).find((candidate) => candidate.name.toLowerCase() === name.toLowerCase() && candidate.namespace_id === namespace.id);
|
|
211
271
|
if (!existing)
|
|
212
272
|
throw caught;
|
|
213
|
-
|
|
214
|
-
return reply(translate("createApp.alreadyLinked", { name: existing.name, id: existing.id, repo: repoUrl }) +
|
|
215
|
-
next(translate("createApp.createdHint")) +
|
|
216
|
-
linkLine(translate("md.dashboard"), dashboardLink(orgSlug, existing.nrn)), {
|
|
217
|
-
application: {
|
|
218
|
-
id: existing.id,
|
|
219
|
-
name: existing.name,
|
|
220
|
-
status: existing.status,
|
|
221
|
-
nrn: existing.nrn,
|
|
222
|
-
},
|
|
223
|
-
reused: true,
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
// Provisioning is async (pending -> active); wait briefly so the common case answers "ready".
|
|
227
|
-
let app = created;
|
|
228
|
-
for (let attempt = 0; attempt < 8 && app.status === "pending"; attempt++) {
|
|
229
|
-
await sleep(delays.appPoll);
|
|
230
|
-
app = await getApplication(context.np, created.id).catch(() => app);
|
|
273
|
+
return reusedAppReply(context, existing, repoUrl);
|
|
231
274
|
}
|
|
232
|
-
const orgSlug = await context.org.organizationSlug();
|
|
233
|
-
const dashboard = linkLine(translate("md.dashboard"), dashboardLink(orgSlug, app.nrn));
|
|
234
|
-
const recentMessages = (app.messages ?? [])
|
|
235
|
-
.slice(-3)
|
|
236
|
-
.map((entry) => `- ${entry.message ?? JSON.stringify(entry)}`)
|
|
237
|
-
.join("\n");
|
|
238
275
|
// A new repo scaffolded from a template is a fresh REMOTE repo the developer has never cloned —
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
};
|
|
247
|
-
const createdHint = scaffolded
|
|
248
|
-
? translate("createApp.createdHintNew", { repo: repoUrl })
|
|
249
|
-
: translate("createApp.createdHint");
|
|
250
|
-
if (app.status === "active") {
|
|
251
|
-
return reply(`${glyph("active")} ${translate("createApp.created", { name, id: app.id, namespace: namespace.name, repo: repoUrl })}${recentMessages ? `\n${recentMessages}` : ""}` +
|
|
252
|
-
next(createdHint) +
|
|
253
|
-
dashboard, structured);
|
|
254
|
-
}
|
|
255
|
-
return reply(`${translate("createApp.pending", { name, id: app.id, status: app.status })}${recentMessages ? `\n${recentMessages}` : ""}` +
|
|
256
|
-
next(translate("createApp.pendingHint", { id: app.id })) +
|
|
257
|
-
dashboard, structured);
|
|
276
|
+
// its next move is to clone-then-build; an import is already local, so it just pushes. The two
|
|
277
|
+
// paths get different guidance (carried by `scaffolded`). Poll briefly so the common case is ready.
|
|
278
|
+
return provisioningReply(context, created, {
|
|
279
|
+
name,
|
|
280
|
+
namespaceName: namespace.name,
|
|
281
|
+
repoUrl,
|
|
282
|
+
scaffolded: args.template_id !== undefined,
|
|
283
|
+
});
|
|
258
284
|
},
|
|
259
285
|
});
|
|
@@ -9,6 +9,71 @@ const slug = (text) => text
|
|
|
9
9
|
.toLowerCase()
|
|
10
10
|
.replace(/[^a-z0-9]+/g, "-")
|
|
11
11
|
.replace(/^-+|-+$/g, "");
|
|
12
|
+
/**
|
|
13
|
+
* Pick which provisioned service to link from the `service` hint. Mirrors requireApp's shape:
|
|
14
|
+
* returns the single matched service, or an `out` reply — a `fail` when the app has no linkable
|
|
15
|
+
* services, or the pick-service panel when the hint is missing/ambiguous (zero or many matches).
|
|
16
|
+
*/
|
|
17
|
+
async function resolveLinkService(context, app, serviceHint, dashboard) {
|
|
18
|
+
const services = (await listAppServices(context.np, app.nrn)).filter((service) => service.status !== "failed");
|
|
19
|
+
if (services.length === 0) {
|
|
20
|
+
return {
|
|
21
|
+
out: fail(translate("createLink.noServices", { app: app.name }) + next(translate("createLink.noServicesHint"))),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const wanted = serviceHint?.toLowerCase();
|
|
25
|
+
const matched = wanted
|
|
26
|
+
? services.filter((service) => service.name.toLowerCase().includes(wanted) || service.id === serviceHint)
|
|
27
|
+
: [];
|
|
28
|
+
if (matched.length === 1)
|
|
29
|
+
return { service: matched[0] };
|
|
30
|
+
// Zero or many matches → let the user pick (the panel lists the candidates, or all services).
|
|
31
|
+
const list = matched.length > 1 ? matched : services;
|
|
32
|
+
const md = `${translate(matched.length > 1 ? "createLink.matchAmbiguous" : "createLink.pickService", {
|
|
33
|
+
app: app.name,
|
|
34
|
+
})}\n\n${table([translate("header.name"), translate("header.status")], list.map((service) => [service.name, service.status]))}${next(translate("createLink.pickHint"))}`;
|
|
35
|
+
return {
|
|
36
|
+
out: reply(md, {
|
|
37
|
+
mode: "pick-service",
|
|
38
|
+
app: `#${app.id}`,
|
|
39
|
+
app_name: app.name,
|
|
40
|
+
services: list,
|
|
41
|
+
dashboard,
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Create the link, or reuse an existing non-failed one on the same target (convergent under
|
|
47
|
+
* retries). A credential link (linkCreate present) is created WITHOUT activate-now and then has its
|
|
48
|
+
* create-action enqueued — the agent provisions it; a plain link activates immediately on creation.
|
|
49
|
+
*/
|
|
50
|
+
async function createOrReuseLink(context, input) {
|
|
51
|
+
const { service, target, linkSpec, linkCreate } = input;
|
|
52
|
+
const existing = (await listLinks(context.np, target.nrn)).find((link) => link.service_id === service.id && link.status !== "failed");
|
|
53
|
+
if (existing) {
|
|
54
|
+
return { link: { id: existing.id, status: existing.status, service_id: service.id }, reused: true };
|
|
55
|
+
}
|
|
56
|
+
const link = await createLink(context.np, {
|
|
57
|
+
name: `lnk ${slug(service.name)}`,
|
|
58
|
+
service_id: service.id,
|
|
59
|
+
entity_nrn: target.nrn,
|
|
60
|
+
specification_id: linkSpec.id,
|
|
61
|
+
dimensions: target.dimensions,
|
|
62
|
+
// No create action → no agent will provision it, so it MUST be sent active or it sticks
|
|
63
|
+
// `pending` forever. With one, OMIT status and enqueue the create action.
|
|
64
|
+
activateImmediately: !linkCreate,
|
|
65
|
+
});
|
|
66
|
+
if (linkCreate) {
|
|
67
|
+
// THE link action — POST /link/{id}/action with the link's own parameters (e.g. the
|
|
68
|
+
// database-user's permissions). Without it the link is `pending` with no credentials.
|
|
69
|
+
await createLinkAction(context.np, link.id, {
|
|
70
|
+
name: `create-${slug(service.name)}-link`,
|
|
71
|
+
specification_id: linkCreate.id,
|
|
72
|
+
parameters: input.parameters ?? {},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return { link, reused: false };
|
|
76
|
+
}
|
|
12
77
|
/**
|
|
13
78
|
* Link a provisioned dependency service to an application or one of its scopes — a SEPARATE
|
|
14
79
|
* operation from provisioning it (`application_service_create`). Creating the link is one or two
|
|
@@ -20,7 +85,7 @@ const slug = (text) => text
|
|
|
20
85
|
export const createLinkTool = defineTool({
|
|
21
86
|
name: TOOL.applicationLinkCreate,
|
|
22
87
|
title: "Link service",
|
|
23
|
-
description:
|
|
88
|
+
description: 'LINK a provisioned dependency service (a database, queue, cache, …) to an application or one of its scopes so its connection details flow in as read-only parameters. Use it for "connect/link the database to <app/scope>", "wire up the queue". Pass `service` (by name) and optionally a `scope` (default: the whole app). A credential link (e.g. a Postgres database-user) provisions a user with the permissions you pick, so call WITHOUT `create:true` first to open the form; the user confirms by submitting. New parameters reach the runtime on the NEXT deploy. Provision the service first with application_service_create.',
|
|
24
89
|
annotations: { destructiveHint: false, openWorldHint: true },
|
|
25
90
|
widget: "service-link",
|
|
26
91
|
errorKey: "createLink.errorLabel",
|
|
@@ -52,29 +117,11 @@ export const createLinkTool = defineTool({
|
|
|
52
117
|
return fail(translate("resolve.noNrn", { app: app.name }));
|
|
53
118
|
const orgSlug = await context.org.organizationSlug();
|
|
54
119
|
const dashboard = dashboardLink(orgSlug, app.nrn);
|
|
55
|
-
// The app's provisioned services — pick which one to link.
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
return
|
|
59
|
-
|
|
60
|
-
const wanted = args.service?.toLowerCase();
|
|
61
|
-
const matched = wanted
|
|
62
|
-
? services.filter((service) => service.name.toLowerCase().includes(wanted) || service.id === args.service)
|
|
63
|
-
: [];
|
|
64
|
-
if (matched.length !== 1) {
|
|
65
|
-
const list = matched.length > 1 ? matched : services;
|
|
66
|
-
const md = `${translate(matched.length > 1 ? "createLink.matchAmbiguous" : "createLink.pickService", {
|
|
67
|
-
app: app.name,
|
|
68
|
-
})}\n\n${table([translate("header.name"), translate("header.status")], list.map((service) => [service.name, service.status]))}${next(translate("createLink.pickHint"))}`;
|
|
69
|
-
return reply(md, {
|
|
70
|
-
mode: "pick-service",
|
|
71
|
-
app: `#${app.id}`,
|
|
72
|
-
app_name: app.name,
|
|
73
|
-
services: list,
|
|
74
|
-
dashboard,
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
const service = matched[0];
|
|
120
|
+
// The app's provisioned services — pick which one to link (or surface the picker / no-services fail).
|
|
121
|
+
const resolvedService = await resolveLinkService(context, { id: app.id, name: app.name, nrn: app.nrn }, args.service, dashboard);
|
|
122
|
+
if ("out" in resolvedService)
|
|
123
|
+
return resolvedService.out;
|
|
124
|
+
const service = resolvedService.service;
|
|
78
125
|
// The link spec that applies to this service + its create-action (the form's params). Prefer the
|
|
79
126
|
// spec that PROVISIONS credentials (use_default_actions) — e.g. Postgres `database-user`.
|
|
80
127
|
const linkSpecs = service.specification_id
|
|
@@ -116,37 +163,17 @@ export const createLinkTool = defineTool({
|
|
|
116
163
|
dashboard,
|
|
117
164
|
});
|
|
118
165
|
}
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
link = await createLink(context.np, {
|
|
128
|
-
name: `lnk ${slug(service.name)}`,
|
|
129
|
-
service_id: service.id,
|
|
130
|
-
entity_nrn: target.nrn,
|
|
131
|
-
specification_id: linkSpec.id,
|
|
132
|
-
dimensions: target.dimensions,
|
|
133
|
-
// No create action → no agent will provision it, so it MUST be sent active or it sticks
|
|
134
|
-
// `pending` forever. With one, OMIT status and enqueue the create action below.
|
|
135
|
-
activateImmediately: !linkCreate,
|
|
136
|
-
});
|
|
137
|
-
if (linkCreate) {
|
|
138
|
-
// THE link action — POST /link/{id}/action with the link's own parameters (e.g. the
|
|
139
|
-
// database-user's permissions). Without it the link is `pending` with no credentials.
|
|
140
|
-
await createLinkAction(context.np, link.id, {
|
|
141
|
-
name: `create-${slug(service.name)}-link`,
|
|
142
|
-
specification_id: linkCreate.id,
|
|
143
|
-
parameters: args.parameters ?? {},
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
}
|
|
166
|
+
// Create the link (or reuse a non-failed one on this target — convergent under retries).
|
|
167
|
+
const { link: linked, reused } = await createOrReuseLink(context, {
|
|
168
|
+
service,
|
|
169
|
+
target,
|
|
170
|
+
linkSpec,
|
|
171
|
+
linkCreate,
|
|
172
|
+
parameters: args.parameters,
|
|
173
|
+
});
|
|
147
174
|
await sleep(delays.appPoll);
|
|
148
|
-
link = await getLink(context.np,
|
|
149
|
-
const headline =
|
|
175
|
+
const link = await getLink(context.np, linked.id).catch(() => linked);
|
|
176
|
+
const headline = reused
|
|
150
177
|
? translate("createLink.reused", { service: service.name, target: target.label, status: link.status })
|
|
151
178
|
: translate("createLink.linking", { service: service.name, target: target.label, status: link.status });
|
|
152
179
|
const md = `${headline}${next(translate("createLink.linkedHint"))}${linkLine(translate("md.dashboard"), dashboard)}`;
|
|
@@ -156,7 +183,7 @@ export const createLinkTool = defineTool({
|
|
|
156
183
|
app_name: app.name,
|
|
157
184
|
service: { id: service.id, name: service.name },
|
|
158
185
|
link: { id: link.id, status: link.status, target: target.label },
|
|
159
|
-
reused
|
|
186
|
+
reused,
|
|
160
187
|
dashboard,
|
|
161
188
|
});
|
|
162
189
|
},
|
|
@@ -8,7 +8,7 @@ import { appArg, requireApp } from "./shared.js";
|
|
|
8
8
|
export const createReleaseTool = defineTool({
|
|
9
9
|
name: TOOL.applicationReleaseCreate,
|
|
10
10
|
title: "Create release",
|
|
11
|
-
description:
|
|
11
|
+
description: 'Cut an active RELEASE from a build (default: the latest successful build; semver auto-bumps the patch) — a release is a deployable, versioned pin of a build. Use it for "cut a release", "create a release from build X" WITHOUT deploying. Usually you do NOT need this directly: application_deployment_create cuts the release for you as part of shipping — reach for this only when the user explicitly wants a release but not a deploy. Convergent: reuses the existing active release for the same build rather than duplicating. Renders the releases panel; deploy a release with application_deployment_create.',
|
|
12
12
|
// Convergent under retries (reuses the active release-for-build), so idempotent.
|
|
13
13
|
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
14
14
|
widget: "releases",
|