@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.
Files changed (57) hide show
  1. package/README.md +10 -0
  2. package/dist/http.js +16 -12
  3. package/dist/i18n.js +6 -0
  4. package/dist/log.js +53 -0
  5. package/dist/np/client.js +10 -1
  6. package/dist/np/context.js +30 -21
  7. package/dist/np/journey.js +14 -4
  8. package/dist/render.js +12 -13
  9. package/dist/surfaces/developer.js +21 -4
  10. package/dist/tool.js +64 -6
  11. package/dist/tools/approvals.js +1 -1
  12. package/dist/tools/builds.js +1 -1
  13. package/dist/tools/create-app.js +158 -132
  14. package/dist/tools/create-link.js +82 -55
  15. package/dist/tools/create-release.js +1 -1
  16. package/dist/tools/create-scope.js +35 -23
  17. package/dist/tools/create-service.js +39 -25
  18. package/dist/tools/delete-link.js +1 -1
  19. package/dist/tools/delete-param.js +1 -1
  20. package/dist/tools/delete-service.js +1 -1
  21. package/dist/tools/deploy.js +52 -30
  22. package/dist/tools/deployments.js +1 -1
  23. package/dist/tools/entity-get.js +1 -1
  24. package/dist/tools/entity-list.js +14 -6
  25. package/dist/tools/find-apps.js +5 -2
  26. package/dist/tools/logs.js +114 -17
  27. package/dist/tools/metrics.js +1 -1
  28. package/dist/tools/overview.js +1 -1
  29. package/dist/tools/params.js +8 -2
  30. package/dist/tools/playbook.js +1 -1
  31. package/dist/tools/releases.js +1 -1
  32. package/dist/tools/services.js +1 -1
  33. package/dist/tools/set-params.js +18 -19
  34. package/dist/tools/shared.js +52 -0
  35. package/dist/tools/status.js +4 -4
  36. package/dist/tools/traffic.js +38 -26
  37. package/dist/tools/update-link.js +1 -1
  38. package/dist/tools/update-service.js +1 -1
  39. package/dist/ui.js +1 -1
  40. package/package.json +5 -1
  41. package/widgets-dist/approvals.html +120 -10
  42. package/widgets-dist/builds.html +127 -17
  43. package/widgets-dist/create-app.html +129 -19
  44. package/widgets-dist/deployments.html +132 -22
  45. package/widgets-dist/find-apps.html +131 -21
  46. package/widgets-dist/logs.html +133 -23
  47. package/widgets-dist/manifest.json +16 -16
  48. package/widgets-dist/metrics.html +120 -10
  49. package/widgets-dist/overview.html +136 -26
  50. package/widgets-dist/params.html +121 -11
  51. package/widgets-dist/releases.html +136 -26
  52. package/widgets-dist/service-action.html +124 -14
  53. package/widgets-dist/service-create.html +123 -13
  54. package/widgets-dist/service-delete.html +120 -10
  55. package/widgets-dist/service-link.html +123 -13
  56. package/widgets-dist/services.html +120 -10
  57. package/widgets-dist/{np-panel.html → status.html} +135 -25
@@ -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 narrowed = accountQuery
17
+ const inAccount = accountQuery
11
18
  ? namespaces.filter((candidate) => (candidate.account_name ?? "").toLowerCase().includes(accountQuery))
12
19
  : namespaces;
13
- const candidates = narrowed.length ? narrowed : namespaces;
14
- if (hints.namespace_id) {
15
- const byId = candidates.find((candidate) => candidate.id === hints.namespace_id);
16
- if (byId)
17
- return byId;
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 contract for the template: honour a passed id, pin the only one, else leave it unselected —
45
- // never silently scaffold the first stack in the list (a wrong stack is worse than a deliberate pick).
46
- const templateId = (prefill.template_id && templates.some((template) => template.id === prefill.template_id)
47
- ? prefill.template_id
48
- : null) ?? (templates.length === 1 ? (templates[0]?.id ?? null) : null);
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: "Create a nullplatform application. 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 with NO repository_url 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 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.",
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 repository_url (the form's Create button, or a deliberate
108
- // call). With none, return the form and create nothing. create_app does NOT read the
109
- // ambient git remote: opening the form, switching namespace, or running the MCP inside some
110
- // repo must never auto-create an app behind the user's back (the bug where "let's create an
111
- // app" silently created the MCP's own repo). The user chooses the repository in the form.
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
- const orgSlug = await context.org.organizationSlug();
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. Narrow to the named account first
151
- // (a namespace name can repeat across accounts), then match/auto-pick the namespace inside it.
152
- // If it still can't be pinned down and there's a real choice, fall back to the form (a picker)
153
- // rather than a dead-end ask.
154
- const skeleton = await context.org.getSkeleton();
155
- const accountQuery = args.account?.toLowerCase();
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
- const orgSlug = await context.org.organizationSlug();
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
- // the next move is to pull it into a folder and build there. An import is already local, so its
240
- // next move is just to push. The two paths get different guidance.
241
- const scaffolded = args.template_id !== undefined;
242
- const structured = {
243
- application: { id: app.id, name, status: app.status, nrn: app.nrn },
244
- scaffolded,
245
- repository_url: repoUrl,
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: "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. Pass `service` to choose which one (by name). 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 it. New parameters reach the runtime on the NEXT deploy.",
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 services = (await listAppServices(context.np, app.nrn)).filter((service) => service.status !== "failed");
57
- if (services.length === 0) {
58
- return fail(translate("createLink.noServices", { app: app.name }) + next(translate("createLink.noServicesHint")));
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
- // ---- CREATE LINK ----
120
- // Convergent: an existing link for this service on this target → reuse, never duplicate.
121
- const existing = (await listLinks(context.np, target.nrn)).find((link) => link.service_id === service.id && link.status !== "failed");
122
- let link;
123
- if (existing) {
124
- link = { id: existing.id, status: existing.status, service_id: service.id };
125
- }
126
- else {
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, link.id).catch(() => link);
149
- const headline = existing
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: Boolean(existing),
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: "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.",
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",