@nullplatform/mcp 0.1.5 → 0.1.7
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/dist/i18n.js +206 -22
- package/dist/md.js +14 -4
- package/dist/np/client.js +3 -0
- package/dist/np/journey.js +236 -24
- package/dist/surfaces/developer.js +1 -1
- package/dist/tool-names.js +7 -0
- package/dist/tools/action-flow.js +94 -0
- package/dist/tools/approvals.js +2 -2
- package/dist/tools/builds.js +23 -6
- package/dist/tools/create-link.js +163 -0
- package/dist/tools/create-service.js +149 -0
- package/dist/tools/delete-flow.js +30 -0
- package/dist/tools/delete-link.js +95 -0
- package/dist/tools/delete-param.js +76 -0
- package/dist/tools/delete-service.js +108 -0
- package/dist/tools/deployments.js +2 -2
- package/dist/tools/index.js +14 -0
- package/dist/tools/logs.js +2 -2
- package/dist/tools/metrics.js +4 -1
- package/dist/tools/overview.js +2 -2
- package/dist/tools/params.js +78 -17
- package/dist/tools/playbook.js +2 -1
- package/dist/tools/releases.js +2 -2
- package/dist/tools/services.js +2 -2
- package/dist/tools/set-params.js +61 -11
- package/dist/tools/shared.js +4 -0
- package/dist/tools/update-link.js +87 -0
- package/dist/tools/update-service.js +92 -0
- package/dist/ui.js +4 -1
- package/package.json +3 -1
- package/skills/starting-a-new-app/SKILL.md +71 -0
- package/widgets-dist/approvals.html +257 -53
- package/widgets-dist/builds.html +261 -57
- package/widgets-dist/create-app.html +252 -48
- package/widgets-dist/deployments.html +253 -49
- package/widgets-dist/find-apps.html +251 -47
- package/widgets-dist/logs.html +256 -52
- package/widgets-dist/manifest.json +16 -13
- package/widgets-dist/metrics.html +261 -57
- package/widgets-dist/np-panel.html +257 -53
- package/widgets-dist/overview.html +261 -57
- package/widgets-dist/params.html +257 -53
- package/widgets-dist/releases.html +259 -55
- package/widgets-dist/service-action.html +1118 -0
- package/widgets-dist/service-create.html +1117 -0
- package/widgets-dist/{playbook.html → service-delete.html} +258 -58
- package/widgets-dist/service-link.html +1117 -0
- package/widgets-dist/services.html +255 -51
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { translate } from "../i18n.js";
|
|
3
|
+
import { dashboardLink, linkLine, next, table } from "../md.js";
|
|
4
|
+
import { createLink, createLinkAction, getLink, linkCreateActionSpec, linkSpecsForService, listAppServices, listLinks, listScopes, } from "../np/journey.js";
|
|
5
|
+
import { defineTool, fail, reply } from "../tool.js";
|
|
6
|
+
import { TOOL } from "../tool-names.js";
|
|
7
|
+
import { appArg, delays, pickScope, requireApp, sleep } from "./shared.js";
|
|
8
|
+
const slug = (text) => text
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
11
|
+
.replace(/^-+|-+$/g, "");
|
|
12
|
+
/**
|
|
13
|
+
* Link a provisioned dependency service to an application or one of its scopes — a SEPARATE
|
|
14
|
+
* operation from provisioning it (`application_service_create`). Creating the link is one or two
|
|
15
|
+
* POSTs depending on whether the link spec carries a create-action: a credential link (Postgres
|
|
16
|
+
* `database-user`) provisions a DB user via `POST /link/{id}/action`; a plain link activates on the
|
|
17
|
+
* `POST /link` itself. `status` is DERIVED, never an input — sending `active` on a credential link
|
|
18
|
+
* would skip provisioning and yield a user-less link.
|
|
19
|
+
*/
|
|
20
|
+
export const createLinkTool = defineTool({
|
|
21
|
+
name: TOOL.applicationLinkCreate,
|
|
22
|
+
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.",
|
|
24
|
+
annotations: { destructiveHint: false, openWorldHint: true },
|
|
25
|
+
widget: "service-link",
|
|
26
|
+
errorKey: "createLink.errorLabel",
|
|
27
|
+
inputSchema: {
|
|
28
|
+
app: appArg,
|
|
29
|
+
service: z
|
|
30
|
+
.string()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Which provisioned service to link, by name (or the service id returned by a provision)"),
|
|
33
|
+
scope: z
|
|
34
|
+
.string()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe("Link to this scope (name or dimension) so its params land there; default: the whole app"),
|
|
37
|
+
parameters: z
|
|
38
|
+
.record(z.unknown())
|
|
39
|
+
.optional()
|
|
40
|
+
.describe("Values for the link create-action parameters (e.g. a database user's permissions)"),
|
|
41
|
+
create: z
|
|
42
|
+
.boolean()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("Set true to actually create the link (the form's submit sets it). Omit to return the form."),
|
|
45
|
+
},
|
|
46
|
+
async handler(args, context) {
|
|
47
|
+
const resolved = await requireApp(context, args);
|
|
48
|
+
if ("out" in resolved)
|
|
49
|
+
return resolved.out;
|
|
50
|
+
const app = resolved.app;
|
|
51
|
+
if (!app.nrn)
|
|
52
|
+
return fail(translate("resolve.noNrn", { app: app.name }));
|
|
53
|
+
const orgSlug = await context.org.organizationSlug();
|
|
54
|
+
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];
|
|
78
|
+
// The link spec that applies to this service + its create-action (the form's params). Prefer the
|
|
79
|
+
// spec that PROVISIONS credentials (use_default_actions) — e.g. Postgres `database-user`.
|
|
80
|
+
const linkSpecs = service.specification_id
|
|
81
|
+
? await linkSpecsForService(context.np, service.specification_id)
|
|
82
|
+
: [];
|
|
83
|
+
const linkSpec = linkSpecs.find((candidate) => candidate.use_default_actions) ?? linkSpecs[0];
|
|
84
|
+
if (!linkSpec) {
|
|
85
|
+
return fail(translate("createLink.noLinkSpec", { service: service.name }) +
|
|
86
|
+
linkLine(translate("md.dashboard"), dashboard));
|
|
87
|
+
}
|
|
88
|
+
const linkCreate = await linkCreateActionSpec(context.np, linkSpec.id);
|
|
89
|
+
// Resolve the target scope (its dimensions decide where the connection params inject); else app.
|
|
90
|
+
const scopes = await listScopes(context.np, app.id);
|
|
91
|
+
let target = { nrn: app.nrn, dimensions: {}, label: app.name };
|
|
92
|
+
if (args.scope) {
|
|
93
|
+
const picked = pickScope(scopes, args.scope);
|
|
94
|
+
if ("scope" in picked && picked.scope.nrn) {
|
|
95
|
+
target = { nrn: picked.scope.nrn, dimensions: {}, label: picked.scope.name };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Form-first: no explicit create OR a required link-param still missing → show the form.
|
|
99
|
+
const requiredKeys = linkCreate?.schema?.required ?? [];
|
|
100
|
+
const missing = requiredKeys.filter((key) => args.parameters?.[key] === undefined);
|
|
101
|
+
if (!args.create || missing.length > 0) {
|
|
102
|
+
return reply(translate("createLink.form", { service: service.name, app: app.name }) +
|
|
103
|
+
next(translate("createLink.formHint")), {
|
|
104
|
+
mode: "form",
|
|
105
|
+
app: `#${app.id}`,
|
|
106
|
+
app_name: app.name,
|
|
107
|
+
service: { id: service.id, name: service.name, status: service.status },
|
|
108
|
+
link_spec_name: linkSpec.name,
|
|
109
|
+
parameters_schema: linkCreate?.schema ?? null,
|
|
110
|
+
scopes: scopes.map((scope) => ({
|
|
111
|
+
id: scope.id,
|
|
112
|
+
name: scope.name,
|
|
113
|
+
dimensions: scope.dimensions ?? {},
|
|
114
|
+
})),
|
|
115
|
+
target: target.label,
|
|
116
|
+
dashboard,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
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
|
+
}
|
|
147
|
+
await sleep(delays.appPoll);
|
|
148
|
+
link = await getLink(context.np, link.id).catch(() => link);
|
|
149
|
+
const headline = existing
|
|
150
|
+
? translate("createLink.reused", { service: service.name, target: target.label, status: link.status })
|
|
151
|
+
: translate("createLink.linking", { service: service.name, target: target.label, status: link.status });
|
|
152
|
+
const md = `${headline}${next(translate("createLink.linkedHint"))}${linkLine(translate("md.dashboard"), dashboard)}`;
|
|
153
|
+
return reply(md, {
|
|
154
|
+
mode: "progress",
|
|
155
|
+
app: `#${app.id}`,
|
|
156
|
+
app_name: app.name,
|
|
157
|
+
service: { id: service.id, name: service.name },
|
|
158
|
+
link: { id: link.id, status: link.status, target: target.label },
|
|
159
|
+
reused: Boolean(existing),
|
|
160
|
+
dashboard,
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { translate } from "../i18n.js";
|
|
3
|
+
import { dashboardLink, linkLine, next, table } from "../md.js";
|
|
4
|
+
import { createServiceAction, getService, listAppServices, listDependencySpecs, provisionService, serviceCreateActionSpec, } from "../np/journey.js";
|
|
5
|
+
import { defineTool, fail, reply } from "../tool.js";
|
|
6
|
+
import { TOOL } from "../tool-names.js";
|
|
7
|
+
import { appArg, delays, requireApp, sleep } from "./shared.js";
|
|
8
|
+
const slug = (text) => text
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
11
|
+
.replace(/^-+|-+$/g, "");
|
|
12
|
+
/** Specs whose name / category / sub-category contain EVERY token of the query (fuzzy "postgres",
|
|
13
|
+
* "sql database", "redis"…). */
|
|
14
|
+
function matchSpecs(specs, query) {
|
|
15
|
+
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
16
|
+
return specs.filter((spec) => {
|
|
17
|
+
const haystack = `${spec.name} ${spec.category ?? ""} ${spec.subCategory ?? ""}`.toLowerCase();
|
|
18
|
+
return tokens.every((token) => haystack.includes(token));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
const specCategory = (spec) => [spec.category, spec.subCategory].filter(Boolean).join(" › ");
|
|
22
|
+
/**
|
|
23
|
+
* Provision a dependency service (the service record + its create action). Linking it to an app or
|
|
24
|
+
* scope is a SEPARATE operation (`application_link_create`) — two distinct platform entities with
|
|
25
|
+
* distinct lifecycles, so two tools (the model provisions, then links).
|
|
26
|
+
*/
|
|
27
|
+
export const createServiceTool = defineTool({
|
|
28
|
+
name: TOOL.applicationServiceCreate,
|
|
29
|
+
title: "Provision service",
|
|
30
|
+
description: 'Provision a dependency service (SQL database, queue, cache, …) for an application. Pass `type` to choose the dependency (e.g. "postgres", "redis"). Provisioning creates real cloud resources (cost-bearing) and runs asynchronously, so call WITHOUT `provision:true` first to open the form; the user confirms by submitting it. Once it is active, LINK it to a scope with application_link_create so its connection details flow in as parameters.',
|
|
31
|
+
annotations: { destructiveHint: false, openWorldHint: true },
|
|
32
|
+
widget: "service-create",
|
|
33
|
+
errorKey: "createService.errorLabel",
|
|
34
|
+
inputSchema: {
|
|
35
|
+
app: appArg,
|
|
36
|
+
type: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('Dependency to provision — matched against the catalog (e.g. "postgres", "redis", "queue")'),
|
|
40
|
+
specification_id: z
|
|
41
|
+
.string()
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("Exact dependency spec id (the form submits this for a deterministic match; else use `type`)"),
|
|
44
|
+
name: z.string().optional().describe("Service name (default: the dependency type)"),
|
|
45
|
+
parameters: z
|
|
46
|
+
.record(z.unknown())
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("Values for the create-action parameters (per the spec's schema shown in the form)"),
|
|
49
|
+
provision: z
|
|
50
|
+
.boolean()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("Set true to actually provision (creates cloud resources). Omit to return the form to fill."),
|
|
53
|
+
},
|
|
54
|
+
async handler(args, context) {
|
|
55
|
+
const resolved = await requireApp(context, args);
|
|
56
|
+
if ("out" in resolved)
|
|
57
|
+
return resolved.out;
|
|
58
|
+
const app = resolved.app;
|
|
59
|
+
if (!app.nrn)
|
|
60
|
+
return fail(translate("resolve.noNrn", { app: app.name }));
|
|
61
|
+
const orgSlug = await context.org.organizationSlug();
|
|
62
|
+
const dashboard = dashboardLink(orgSlug, app.nrn);
|
|
63
|
+
// The provisionable catalog (core API — carries the action specs the write path needs).
|
|
64
|
+
const specs = await listDependencySpecs(context.np, app.nrn);
|
|
65
|
+
if (specs.length === 0) {
|
|
66
|
+
return fail(translate("createService.noSpecs", { app: app.name }) +
|
|
67
|
+
linkLine(translate("md.dashboard"), dashboard));
|
|
68
|
+
}
|
|
69
|
+
// Match the requested dependency: exact id (form submit) wins; else fuzzy `type`. None or
|
|
70
|
+
// ambiguous → return the catalog to pick from.
|
|
71
|
+
const direct = args.specification_id
|
|
72
|
+
? specs.find((spec) => spec.id === args.specification_id)
|
|
73
|
+
: undefined;
|
|
74
|
+
const matches = direct ? [direct] : args.type ? matchSpecs(specs, args.type) : [];
|
|
75
|
+
if (matches.length !== 1) {
|
|
76
|
+
const list = matches.length > 1 ? matches : specs;
|
|
77
|
+
const heading = translate(matches.length > 1 ? "createService.matchAmbiguous" : "createService.pickSpec", {
|
|
78
|
+
app: app.name,
|
|
79
|
+
query: args.type ?? "",
|
|
80
|
+
count: specs.length,
|
|
81
|
+
});
|
|
82
|
+
const md = `${heading}\n\n${table([translate("header.name"), translate("createService.category"), translate("createService.provider")], list.map((spec) => [spec.name, specCategory(spec), spec.provider ?? ""]))}${next(translate("createService.pickHint"))}`;
|
|
83
|
+
return reply(md, { mode: "pick-spec", app: `#${app.id}`, app_name: app.name, specs: list, dashboard });
|
|
84
|
+
}
|
|
85
|
+
const spec = matches[0];
|
|
86
|
+
const createAction = await serviceCreateActionSpec(context.np, spec.id);
|
|
87
|
+
const name = (args.name ?? spec.name).trim();
|
|
88
|
+
// Form-first: no explicit provision OR a required create-action param still missing → show the
|
|
89
|
+
// form (the submit IS the cost confirmation — no silent money-spend).
|
|
90
|
+
const requiredKeys = createAction?.schema?.required ?? [];
|
|
91
|
+
const missing = requiredKeys.filter((key) => args.parameters?.[key] === undefined);
|
|
92
|
+
if (!args.provision || missing.length > 0) {
|
|
93
|
+
return reply(translate("createService.form", { spec: spec.name, app: app.name }) +
|
|
94
|
+
next(translate("createService.formHint")), {
|
|
95
|
+
mode: "form",
|
|
96
|
+
app: `#${app.id}`,
|
|
97
|
+
app_name: app.name,
|
|
98
|
+
spec: {
|
|
99
|
+
id: spec.id,
|
|
100
|
+
name: spec.name,
|
|
101
|
+
category: specCategory(spec),
|
|
102
|
+
provider: spec.provider ?? null,
|
|
103
|
+
},
|
|
104
|
+
parameters_schema: createAction?.schema ?? null,
|
|
105
|
+
name_default: name,
|
|
106
|
+
dashboard,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// ---- PROVISION (cost-bearing) ----
|
|
110
|
+
// Convergent under retries: a same-name dependency already on the app (not failed) → reuse it,
|
|
111
|
+
// never spin up a second one.
|
|
112
|
+
const existing = (await listAppServices(context.np, app.nrn)).find((service) => service.name.toLowerCase() === name.toLowerCase() && service.status !== "failed");
|
|
113
|
+
let service = existing
|
|
114
|
+
? { id: existing.id, name: existing.name, status: existing.status }
|
|
115
|
+
: await provisionService(context.np, {
|
|
116
|
+
name,
|
|
117
|
+
specification_id: spec.id,
|
|
118
|
+
entity_nrn: app.nrn,
|
|
119
|
+
linkable_to: [app.nrn],
|
|
120
|
+
});
|
|
121
|
+
if (!existing && createAction) {
|
|
122
|
+
// THE create action — POST /service/{id}/action. Without it the service is `pending` forever
|
|
123
|
+
// (the external agent only processes the enqueued create action).
|
|
124
|
+
await createServiceAction(context.np, service.id, {
|
|
125
|
+
name: `create-${slug(name)}`,
|
|
126
|
+
specification_id: createAction.id,
|
|
127
|
+
parameters: args.parameters ?? {},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
await sleep(delays.appPoll);
|
|
131
|
+
service = await getService(context.np, service.id).catch(() => service);
|
|
132
|
+
const headline = existing
|
|
133
|
+
? translate("createService.reused", { name: service.name, status: service.status })
|
|
134
|
+
: translate("createService.provisioning", {
|
|
135
|
+
spec: spec.name,
|
|
136
|
+
name: service.name,
|
|
137
|
+
status: service.status,
|
|
138
|
+
});
|
|
139
|
+
const md = `${headline}${next(translate("createService.provisioningHint", { name: service.name }))}${linkLine(translate("md.dashboard"), dashboard)}`;
|
|
140
|
+
return reply(md, {
|
|
141
|
+
mode: "progress",
|
|
142
|
+
app: `#${app.id}`,
|
|
143
|
+
app_name: app.name,
|
|
144
|
+
service: { id: service.id, name: service.name, status: service.status, spec: spec.name },
|
|
145
|
+
reused: Boolean(existing),
|
|
146
|
+
dashboard,
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { translate } from "../i18n.js";
|
|
2
|
+
import { linkLine, next } from "../md.js";
|
|
3
|
+
import { reply } from "../tool.js";
|
|
4
|
+
/**
|
|
5
|
+
* The destructive delete flow shared by `application_service_delete` and `application_link_delete`:
|
|
6
|
+
* confirm-first (nothing is destroyed without `confirm:true`, the widget's danger button sets it),
|
|
7
|
+
* then run the delete and report whether it finished or is deprovisioning. Delete is naturally
|
|
8
|
+
* idempotent — deleting an already-gone entity is reported, never an error.
|
|
9
|
+
*/
|
|
10
|
+
export async function deleteFlow(config, args) {
|
|
11
|
+
const base = {
|
|
12
|
+
entity: config.entity,
|
|
13
|
+
tool: config.tool,
|
|
14
|
+
app: config.appRef,
|
|
15
|
+
app_name: config.appName,
|
|
16
|
+
target: { id: config.entityId, name: config.entityName, status: config.entityStatus },
|
|
17
|
+
dashboard: config.dashboard,
|
|
18
|
+
};
|
|
19
|
+
// Confirm-first: a delete is destructive, so the submit IS the confirmation — no silent destroy.
|
|
20
|
+
if (!args.confirm) {
|
|
21
|
+
return reply(translate("deleteEntity.confirm", { name: config.entityName }) +
|
|
22
|
+
next(translate("deleteEntity.confirmHint")), { ...base, mode: "confirm" });
|
|
23
|
+
}
|
|
24
|
+
const { async } = await config.performDelete();
|
|
25
|
+
const headline = async
|
|
26
|
+
? translate("deleteEntity.deprovisioning", { name: config.entityName })
|
|
27
|
+
: translate("deleteEntity.removed", { name: config.entityName });
|
|
28
|
+
const md = `${headline}${async ? next(translate("deleteEntity.deprovisioningHint")) : ""}${linkLine(translate("md.dashboard"), config.dashboard)}`;
|
|
29
|
+
return reply(md, { ...base, mode: "progress", removed: !async, deprovisioning: async });
|
|
30
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { translate } from "../i18n.js";
|
|
3
|
+
import { dashboardLink, next, table } from "../md.js";
|
|
4
|
+
import { createLinkAction, deleteLink, linkActionSpecs, listLinks } from "../np/journey.js";
|
|
5
|
+
import { defineTool, fail, reply } from "../tool.js";
|
|
6
|
+
import { TOOL } from "../tool-names.js";
|
|
7
|
+
import { deleteFlow } from "./delete-flow.js";
|
|
8
|
+
import { appArg, requireApp } from "./shared.js";
|
|
9
|
+
const slug = (text) => text
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
12
|
+
.replace(/^-+|-+$/g, "");
|
|
13
|
+
/**
|
|
14
|
+
* Delete a link (a service↔application connection). A link whose spec has a delete-action runs it so
|
|
15
|
+
* an agent deprovisions credentials (DB user, permissions) before the record is removed; a plain link
|
|
16
|
+
* (no delete-action) is removed directly. Deleting a link removes the parameters it exported — a
|
|
17
|
+
* redeploy applies that to the runtime.
|
|
18
|
+
*/
|
|
19
|
+
export const deleteLinkTool = defineTool({
|
|
20
|
+
name: TOOL.applicationLinkDelete,
|
|
21
|
+
title: "Delete link",
|
|
22
|
+
description: "Delete a link between a service and an application, removing the parameters it exported. Pass `link` to choose which one (by name); call WITHOUT `confirm:true` first to preview, the user confirms by submitting. A credential link deprovisions its database user before the record is removed; a redeploy applies the parameter removal to the runtime.",
|
|
23
|
+
annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
24
|
+
widget: "service-delete",
|
|
25
|
+
errorKey: "deleteEntity.linkErrorLabel",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
app: appArg,
|
|
28
|
+
link: z.string().optional().describe("Which link to delete, by name (or id)"),
|
|
29
|
+
confirm: z
|
|
30
|
+
.boolean()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Set true to actually delete (the confirm button sets it). Omit to preview."),
|
|
33
|
+
},
|
|
34
|
+
async handler(args, context) {
|
|
35
|
+
const resolved = await requireApp(context, args);
|
|
36
|
+
if ("out" in resolved)
|
|
37
|
+
return resolved.out;
|
|
38
|
+
const app = resolved.app;
|
|
39
|
+
if (!app.nrn)
|
|
40
|
+
return fail(translate("resolve.noNrn", { app: app.name }));
|
|
41
|
+
const orgSlug = await context.org.organizationSlug();
|
|
42
|
+
const dashboard = dashboardLink(orgSlug, app.nrn);
|
|
43
|
+
const links = await listLinks(context.np, app.nrn);
|
|
44
|
+
if (links.length === 0) {
|
|
45
|
+
return fail(translate("deleteEntity.noLinks", { app: app.name }));
|
|
46
|
+
}
|
|
47
|
+
const wanted = args.link?.toLowerCase();
|
|
48
|
+
const matched = wanted
|
|
49
|
+
? links.filter((link) => link.name.toLowerCase().includes(wanted) || link.id === args.link)
|
|
50
|
+
: [];
|
|
51
|
+
if (matched.length !== 1) {
|
|
52
|
+
const list = matched.length > 1 ? matched : links;
|
|
53
|
+
const md = `${translate(matched.length > 1 ? "deleteEntity.linkAmbiguous" : "deleteEntity.pickLink", {
|
|
54
|
+
app: app.name,
|
|
55
|
+
})}\n\n${table([translate("header.name"), translate("header.status")], list.map((link) => [link.name, link.status]))}${next(translate("deleteEntity.pickLinkHint"))}`;
|
|
56
|
+
return reply(md, {
|
|
57
|
+
mode: "pick-target",
|
|
58
|
+
entity: "link",
|
|
59
|
+
tool: TOOL.applicationLinkDelete,
|
|
60
|
+
app: `#${app.id}`,
|
|
61
|
+
app_name: app.name,
|
|
62
|
+
targets: list,
|
|
63
|
+
dashboard,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const link = matched[0];
|
|
67
|
+
const config = {
|
|
68
|
+
tool: TOOL.applicationLinkDelete,
|
|
69
|
+
entity: "link",
|
|
70
|
+
appRef: `#${app.id}`,
|
|
71
|
+
appName: app.name,
|
|
72
|
+
entityId: link.id,
|
|
73
|
+
entityName: link.name,
|
|
74
|
+
entityStatus: link.status,
|
|
75
|
+
dashboard,
|
|
76
|
+
performDelete: async () => {
|
|
77
|
+
// A delete-action deprovisions (DB user, grants); without one, a direct DELETE removes it.
|
|
78
|
+
const deleteSpec = link.specification_id
|
|
79
|
+
? (await linkActionSpecs(context.np, link.specification_id)).find((spec) => spec.type === "delete")
|
|
80
|
+
: undefined;
|
|
81
|
+
if (deleteSpec) {
|
|
82
|
+
await createLinkAction(context.np, link.id, {
|
|
83
|
+
name: `delete-${slug(link.name)}`,
|
|
84
|
+
specification_id: deleteSpec.id,
|
|
85
|
+
parameters: {},
|
|
86
|
+
});
|
|
87
|
+
return { async: true };
|
|
88
|
+
}
|
|
89
|
+
await deleteLink(context.np, link.id);
|
|
90
|
+
return { async: false };
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
return deleteFlow(config, args);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { translate } from "../i18n.js";
|
|
3
|
+
import { next } from "../md.js";
|
|
4
|
+
import { deleteParameter, listParameters, listScopes } from "../np/journey.js";
|
|
5
|
+
import { defineTool, errorMessage, fail, reply } from "../tool.js";
|
|
6
|
+
import { TOOL } from "../tool-names.js";
|
|
7
|
+
import { appArg, requireApp } from "./shared.js";
|
|
8
|
+
/**
|
|
9
|
+
* Delete a configuration parameter (the whole definition and all its values). Link-injected
|
|
10
|
+
* parameters are `read_only` — they're removed by deleting the link, not here — so the tool refuses
|
|
11
|
+
* those and points at `application_link_delete`. The change applies on the next deploy.
|
|
12
|
+
*/
|
|
13
|
+
export const deleteParamTool = defineTool({
|
|
14
|
+
name: TOOL.applicationParameterDelete,
|
|
15
|
+
title: "Delete parameter",
|
|
16
|
+
description: "Delete an application configuration parameter (the whole variable and all its values). Pass `name`; call WITHOUT `confirm:true` first to preview, the user confirms by submitting. Read-only parameters injected by a service link can't be deleted here — delete the link instead. Applies on the next deploy.",
|
|
17
|
+
annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
18
|
+
widget: "params",
|
|
19
|
+
errorKey: "deleteParam.errorLabel",
|
|
20
|
+
inputSchema: {
|
|
21
|
+
app: appArg,
|
|
22
|
+
name: z.string().describe("The parameter (variable) name to delete, e.g. MAX_PAGE_SIZE"),
|
|
23
|
+
confirm: z
|
|
24
|
+
.boolean()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("Set true to actually delete (the confirm button sets it). Omit to preview."),
|
|
27
|
+
},
|
|
28
|
+
async handler(args, context) {
|
|
29
|
+
const resolved = await requireApp(context, args);
|
|
30
|
+
if ("out" in resolved)
|
|
31
|
+
return resolved.out;
|
|
32
|
+
const app = resolved.app;
|
|
33
|
+
if (!app.nrn)
|
|
34
|
+
return fail(translate("resolve.noNrn", { app: app.name }));
|
|
35
|
+
const nrn = app.nrn;
|
|
36
|
+
const scopes = await listScopes(context.np, app.id).catch(() => []);
|
|
37
|
+
const scopeData = scopes.map((scope) => ({
|
|
38
|
+
id: scope.id,
|
|
39
|
+
name: scope.name,
|
|
40
|
+
dimensions: scope.dimensions ?? {},
|
|
41
|
+
}));
|
|
42
|
+
const renderSet = async (extra) => {
|
|
43
|
+
const remaining = await listParameters(context.np, nrn).catch(() => undefined);
|
|
44
|
+
return {
|
|
45
|
+
app: `#${app.id}`,
|
|
46
|
+
app_name: app.name,
|
|
47
|
+
scopes: scopeData,
|
|
48
|
+
...(remaining === undefined ? { available: false } : { available: true, params: remaining }),
|
|
49
|
+
...extra,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
const parameters = (await listParameters(context.np, nrn)) ?? [];
|
|
53
|
+
const target = parameters.find((param) => param.name.toLowerCase() === args.name.toLowerCase());
|
|
54
|
+
if (!target) {
|
|
55
|
+
// Idempotent: a name that isn't there (already deleted, or a typo) reports done, never errors.
|
|
56
|
+
return reply(translate("deleteParam.absent", { name: args.name, app: app.name }), await renderSet({ deleted: args.name, removed: true }));
|
|
57
|
+
}
|
|
58
|
+
if (target.read_only) {
|
|
59
|
+
return fail(translate("deleteParam.readOnly", { name: target.name }) +
|
|
60
|
+
next(translate("deleteParam.readOnlyHint")));
|
|
61
|
+
}
|
|
62
|
+
// Confirm-first: deleting a parameter is destructive, so the submit IS the confirmation.
|
|
63
|
+
if (!args.confirm) {
|
|
64
|
+
return reply(translate("deleteParam.confirm", { name: target.name, app: app.name }) +
|
|
65
|
+
next(translate("deleteParam.confirmHint")), await renderSet({ pending_delete: target.name }));
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
await deleteParameter(context.np, target.id);
|
|
69
|
+
}
|
|
70
|
+
catch (caught) {
|
|
71
|
+
return fail(translate("deleteParam.failed", { name: target.name, message: errorMessage(caught) }));
|
|
72
|
+
}
|
|
73
|
+
return reply(translate("deleteParam.deleted", { name: target.name, app: app.name }) +
|
|
74
|
+
next(translate("deleteParam.deletedHint")), await renderSet({ deleted: target.name, removed: true }));
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { translate } from "../i18n.js";
|
|
3
|
+
import { dashboardLink, next, table } from "../md.js";
|
|
4
|
+
import { createServiceAction, deleteService, listAppServices, listLinks, serviceActionSpecs, } from "../np/journey.js";
|
|
5
|
+
import { defineTool, fail, reply } from "../tool.js";
|
|
6
|
+
import { TOOL } from "../tool-names.js";
|
|
7
|
+
import { deleteFlow } from "./delete-flow.js";
|
|
8
|
+
import { appArg, requireApp } from "./shared.js";
|
|
9
|
+
const slug = (text) => text
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
12
|
+
.replace(/^-+|-+$/g, "");
|
|
13
|
+
/**
|
|
14
|
+
* Delete a provisioned service and destroy its resources. A `failed` service (no provisioned
|
|
15
|
+
* resources) deletes directly; an active one runs its delete-action so an agent deprovisions
|
|
16
|
+
* (DB, pods, …) before the record is removed. A service can't be deleted while it has links — the
|
|
17
|
+
* tool surfaces that rather than orphaning them.
|
|
18
|
+
*/
|
|
19
|
+
export const deleteServiceTool = defineTool({
|
|
20
|
+
name: TOOL.applicationServiceDelete,
|
|
21
|
+
title: "Delete service",
|
|
22
|
+
description: "Delete a provisioned service and destroy its cloud resources. Pass `service` to choose which one (by name); call WITHOUT `confirm:true` first to see what will be destroyed, the user confirms by submitting. A service with active links can't be deleted — remove the links first (application_link_delete).",
|
|
23
|
+
annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
24
|
+
widget: "service-delete",
|
|
25
|
+
errorKey: "deleteEntity.serviceErrorLabel",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
app: appArg,
|
|
28
|
+
service: z.string().optional().describe("Which provisioned service to delete, by name (or id)"),
|
|
29
|
+
confirm: z
|
|
30
|
+
.boolean()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Set true to actually delete (the confirm button sets it). Omit to preview."),
|
|
33
|
+
},
|
|
34
|
+
async handler(args, context) {
|
|
35
|
+
const resolved = await requireApp(context, args);
|
|
36
|
+
if ("out" in resolved)
|
|
37
|
+
return resolved.out;
|
|
38
|
+
const app = resolved.app;
|
|
39
|
+
if (!app.nrn)
|
|
40
|
+
return fail(translate("resolve.noNrn", { app: app.name }));
|
|
41
|
+
const orgSlug = await context.org.organizationSlug();
|
|
42
|
+
const dashboard = dashboardLink(orgSlug, app.nrn);
|
|
43
|
+
const services = await listAppServices(context.np, app.nrn);
|
|
44
|
+
if (services.length === 0) {
|
|
45
|
+
return fail(translate("deleteEntity.noServices", { app: app.name }));
|
|
46
|
+
}
|
|
47
|
+
const wanted = args.service?.toLowerCase();
|
|
48
|
+
const matched = wanted
|
|
49
|
+
? services.filter((service) => service.name.toLowerCase().includes(wanted) || service.id === args.service)
|
|
50
|
+
: [];
|
|
51
|
+
if (matched.length !== 1) {
|
|
52
|
+
const list = matched.length > 1 ? matched : services;
|
|
53
|
+
const md = `${translate(matched.length > 1 ? "deleteEntity.serviceAmbiguous" : "deleteEntity.pickService", {
|
|
54
|
+
app: app.name,
|
|
55
|
+
})}\n\n${table([translate("header.name"), translate("header.status")], list.map((service) => [service.name, service.status]))}${next(translate("deleteEntity.pickServiceHint"))}`;
|
|
56
|
+
return reply(md, {
|
|
57
|
+
mode: "pick-target",
|
|
58
|
+
entity: "service",
|
|
59
|
+
tool: TOOL.applicationServiceDelete,
|
|
60
|
+
app: `#${app.id}`,
|
|
61
|
+
app_name: app.name,
|
|
62
|
+
targets: list,
|
|
63
|
+
dashboard,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const service = matched[0];
|
|
67
|
+
// A service can't be deleted while links depend on it — surface that instead of orphaning them.
|
|
68
|
+
if (args.confirm) {
|
|
69
|
+
const blockingLinks = (await listLinks(context.np, app.nrn)).filter((link) => link.service_id === service.id && link.status !== "failed");
|
|
70
|
+
if (blockingLinks.length > 0) {
|
|
71
|
+
return fail(translate("deleteEntity.hasLinks", { name: service.name, count: blockingLinks.length }) +
|
|
72
|
+
next(translate("deleteEntity.hasLinksHint")));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const config = {
|
|
76
|
+
tool: TOOL.applicationServiceDelete,
|
|
77
|
+
entity: "service",
|
|
78
|
+
appRef: `#${app.id}`,
|
|
79
|
+
appName: app.name,
|
|
80
|
+
entityId: service.id,
|
|
81
|
+
entityName: service.name,
|
|
82
|
+
entityStatus: service.status,
|
|
83
|
+
dashboard,
|
|
84
|
+
performDelete: async () => {
|
|
85
|
+
// A failed service has no provisioned resources → direct DELETE (200), no deprovisioning.
|
|
86
|
+
if (service.status === "failed") {
|
|
87
|
+
await deleteService(context.np, service.id);
|
|
88
|
+
return { async: false };
|
|
89
|
+
}
|
|
90
|
+
// An active service deprovisions via its delete-action; without one, force-delete the record.
|
|
91
|
+
const deleteSpec = service.specification_id
|
|
92
|
+
? (await serviceActionSpecs(context.np, service.specification_id)).find((spec) => spec.type === "delete")
|
|
93
|
+
: undefined;
|
|
94
|
+
if (deleteSpec) {
|
|
95
|
+
await createServiceAction(context.np, service.id, {
|
|
96
|
+
name: `delete-${slug(service.name)}`,
|
|
97
|
+
specification_id: deleteSpec.id,
|
|
98
|
+
parameters: {},
|
|
99
|
+
});
|
|
100
|
+
return { async: true };
|
|
101
|
+
}
|
|
102
|
+
await deleteService(context.np, service.id, true);
|
|
103
|
+
return { async: false };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
return deleteFlow(config, args);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { translate } from "../i18n.js";
|
|
2
|
+
import { plural, translate } from "../i18n.js";
|
|
3
3
|
import { ago, glyph, next, table } from "../md.js";
|
|
4
4
|
import { isDeploymentTerminal, listDeployments, listReleases, listScopes, } from "../np/journey.js";
|
|
5
5
|
import { defineTool, reply } from "../tool.js";
|
|
@@ -60,7 +60,7 @@ export const deploymentsTool = defineTool({
|
|
|
60
60
|
return children[0]?.status ?? "pending";
|
|
61
61
|
};
|
|
62
62
|
const markdown = [
|
|
63
|
-
|
|
63
|
+
plural(rows.length, "deploymentList.title.one", "deploymentList.title.many", { app: app.name }),
|
|
64
64
|
"",
|
|
65
65
|
table([
|
|
66
66
|
translate("header.scope"),
|