@nullplatform/mcp 0.1.6 → 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/np/client.js +3 -0
- package/dist/np/journey.js +236 -24
- 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 +261 -57
- package/widgets-dist/builds.html +256 -52
- package/widgets-dist/create-app.html +252 -48
- package/widgets-dist/deployments.html +251 -47
- 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 +256 -52
- package/widgets-dist/overview.html +256 -52
- package/widgets-dist/params.html +257 -53
- package/widgets-dist/releases.html +257 -53
- 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 +249 -45
package/dist/np/journey.js
CHANGED
|
@@ -16,6 +16,17 @@ export function bumpSemver(semver) {
|
|
|
16
16
|
const parts = /^(v?)(\d+)\.(\d+)\.(\d+)/.exec(semver ?? "");
|
|
17
17
|
return parts ? `${parts[1]}${parts[2]}.${parts[3]}.${Number(parts[4]) + 1}` : "0.0.1";
|
|
18
18
|
}
|
|
19
|
+
function mapBuild(build) {
|
|
20
|
+
return {
|
|
21
|
+
id: build.id,
|
|
22
|
+
status: build.status,
|
|
23
|
+
branch: build.branch,
|
|
24
|
+
commit: String(build.commit?.id ?? build.commit_id ?? "") || undefined,
|
|
25
|
+
commit_url: build.commit?.permalink,
|
|
26
|
+
description: build.description ?? undefined,
|
|
27
|
+
created_at: build.created_at,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
19
30
|
export async function listBuilds(np, applicationId, options = {}) {
|
|
20
31
|
const query = {
|
|
21
32
|
application_id: applicationId,
|
|
@@ -29,13 +40,13 @@ export async function listBuilds(np, applicationId, options = {}) {
|
|
|
29
40
|
if (options.offset)
|
|
30
41
|
query.offset = options.offset;
|
|
31
42
|
const page = await np.get("/build", query).catch(() => ({ results: [] }));
|
|
32
|
-
return (page.results ?? []).map(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
return (page.results ?? []).map(mapBuild);
|
|
44
|
+
}
|
|
45
|
+
/** A single build by id (GET /build/{id}) — titles a build's asset view with its real status,
|
|
46
|
+
* commit and description even when reached directly (not drilled in from the list). */
|
|
47
|
+
export async function getBuild(np, buildId) {
|
|
48
|
+
const raw = await np.get(`/build/${buildId}`).catch(() => null);
|
|
49
|
+
return raw ? mapBuild(raw) : null;
|
|
39
50
|
}
|
|
40
51
|
function mapRelease(raw) {
|
|
41
52
|
return {
|
|
@@ -232,11 +243,43 @@ export async function deploymentAction(np, deploymentId, action) {
|
|
|
232
243
|
status: action === "finalize" ? "finalizing" : "cancelling",
|
|
233
244
|
});
|
|
234
245
|
}
|
|
246
|
+
/** The scope id encoded in a value's NRN (…:scope=<id>), or null for an application-level value. */
|
|
247
|
+
function scopeIdFromNrn(nrn) {
|
|
248
|
+
const match = nrn?.match(/:scope=(\d+)/);
|
|
249
|
+
return match ? Number(match[1]) : null;
|
|
250
|
+
}
|
|
251
|
+
function mapParameter(raw) {
|
|
252
|
+
const secret = !!raw.secret;
|
|
253
|
+
return {
|
|
254
|
+
id: raw.id,
|
|
255
|
+
name: raw.name,
|
|
256
|
+
variable: raw.variable,
|
|
257
|
+
destination_path: raw.destination_path,
|
|
258
|
+
type: raw.type,
|
|
259
|
+
secret,
|
|
260
|
+
read_only: raw.read_only,
|
|
261
|
+
values: (raw.values ?? []).map((entry) => {
|
|
262
|
+
const dimensions = entry.dimensions && Object.keys(entry.dimensions).length ? entry.dimensions : null;
|
|
263
|
+
const scopeId = scopeIdFromNrn(entry.nrn);
|
|
264
|
+
return {
|
|
265
|
+
id: entry.id,
|
|
266
|
+
// a value row only exists where a value is set, so secrets become a fixed marker (never plaintext)
|
|
267
|
+
value: secret ? "•••" : entry.value,
|
|
268
|
+
nrn: entry.nrn,
|
|
269
|
+
scope_id: scopeId,
|
|
270
|
+
dimensions,
|
|
271
|
+
base: !scopeId && !dimensions,
|
|
272
|
+
};
|
|
273
|
+
}),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
235
276
|
/**
|
|
236
|
-
* Upsert parameters
|
|
237
|
-
*
|
|
238
|
-
* NRN) and just add a value; only
|
|
239
|
-
*
|
|
277
|
+
* Upsert parameters, optionally targeting a scope (pin `nrn`) or a dimension set (`dimensions`).
|
|
278
|
+
* Agents retry, and re-running "set DATABASE_URL" must not accrete a duplicate definition — so
|
|
279
|
+
* reuse the existing definition (matched by variable/name at this NRN) and just add a value; only
|
|
280
|
+
* POST a new definition when none exists. The value write is convergent on (nrn, dimensions,
|
|
281
|
+
* value) server-side, so a retried set is a no-op new version, never a duplicate. Reports how
|
|
282
|
+
* many definitions were created vs reused.
|
|
240
283
|
*/
|
|
241
284
|
export async function setParameters(np, nrn, params) {
|
|
242
285
|
const existing = (await listParameters(np, nrn)) ?? [];
|
|
@@ -261,28 +304,24 @@ export async function setParameters(np, nrn, params) {
|
|
|
261
304
|
definitionId = definition.id;
|
|
262
305
|
created++;
|
|
263
306
|
}
|
|
264
|
-
|
|
307
|
+
const valueBody = { nrn: param.nrn ?? nrn, value: param.value };
|
|
308
|
+
if (param.dimensions && Object.keys(param.dimensions).length) {
|
|
309
|
+
valueBody.dimensions = param.dimensions;
|
|
310
|
+
}
|
|
311
|
+
await np.post(`/parameter/${definitionId}/values`, { values: [valueBody] });
|
|
265
312
|
}
|
|
266
313
|
return { created, updated };
|
|
267
314
|
}
|
|
268
|
-
/** Read parameters — NRN-scoped on the public API
|
|
315
|
+
/** Read parameters with their full value set — NRN-scoped on the public API. Pass an application
|
|
316
|
+
* NRN to get EVERY value (all dimensions + scopes); pass a scope NRN and the platform collapses
|
|
317
|
+
* each parameter to the single effective value for that scope. undefined when unavailable. */
|
|
269
318
|
export async function listParameters(np, nrn, options = {}) {
|
|
270
319
|
try {
|
|
271
320
|
const query = { nrn, limit: options.limit ?? 100 };
|
|
272
321
|
if (options.offset)
|
|
273
322
|
query.offset = options.offset;
|
|
274
323
|
const page = await np.get("/parameter", query);
|
|
275
|
-
return (page.results ?? []).map(
|
|
276
|
-
id: parameter.id,
|
|
277
|
-
name: parameter.name,
|
|
278
|
-
variable: parameter.variable,
|
|
279
|
-
type: parameter.type,
|
|
280
|
-
secret: !!parameter.secret,
|
|
281
|
-
values: (parameter.values ?? []).map((entry) => ({
|
|
282
|
-
value: parameter.secret ? "•••" : entry.value,
|
|
283
|
-
nrn: entry.nrn,
|
|
284
|
-
})),
|
|
285
|
-
}));
|
|
324
|
+
return (page.results ?? []).map(mapParameter);
|
|
286
325
|
}
|
|
287
326
|
catch {
|
|
288
327
|
return undefined;
|
|
@@ -436,3 +475,176 @@ export async function listServiceSpecifications(bff, nrn) {
|
|
|
436
475
|
.catch(() => ({ results: [] }));
|
|
437
476
|
return (page.results ?? []).map((spec) => ({ id: spec.id, name: spec.name ?? spec.id }));
|
|
438
477
|
}
|
|
478
|
+
// ---- service provisioning & linking (WRITES — core API, like scopes/deployments) ----
|
|
479
|
+
//
|
|
480
|
+
// Grounded against the public service-api OpenAPI (request/response SHAPE) and the platform team's
|
|
481
|
+
// np-developer-actions skill (BEHAVIOUR — the working implementation of exactly this flow).
|
|
482
|
+
//
|
|
483
|
+
// Provisioning is TWO sequential POSTs: `POST /service` creates the record in `pending`, then
|
|
484
|
+
// `POST /service/{id}/action` enqueues the "create" action an external agent processes to `active`
|
|
485
|
+
// (without #2 the service is `pending` forever). Linking is one OR two POSTs depending on the LINK
|
|
486
|
+
// spec's `use_default_actions` — the SQL landmine, encoded in `linkService` so `status` is derived,
|
|
487
|
+
// never an input. Reads `.catch` to empty; writes throw so the tool surfaces the platform error.
|
|
488
|
+
const TERMINAL_SERVICE = new Set(["active", "failed", "cancelled"]);
|
|
489
|
+
export const isServiceTerminal = (status) => TERMINAL_SERVICE.has(status ?? "");
|
|
490
|
+
/**
|
|
491
|
+
* Provisionable dependency specs at an entity NRN, read from the CORE API (not the BFF projection
|
|
492
|
+
* that `listServiceSpecifications` uses) so the write flow can reach the action specs and
|
|
493
|
+
* `use_default_actions` the dashboard list omits.
|
|
494
|
+
*/
|
|
495
|
+
export async function listDependencySpecs(np, nrn) {
|
|
496
|
+
const page = await np
|
|
497
|
+
.get("/service_specification", { nrn, type: "dependency", limit: 100 })
|
|
498
|
+
.catch(() => ({ results: [] }));
|
|
499
|
+
return (page.results ?? []).map((spec) => ({
|
|
500
|
+
id: spec.id,
|
|
501
|
+
name: spec.name ?? spec.id,
|
|
502
|
+
category: spec.selectors?.category,
|
|
503
|
+
subCategory: spec.selectors?.sub_category,
|
|
504
|
+
provider: spec.selectors?.provider,
|
|
505
|
+
}));
|
|
506
|
+
}
|
|
507
|
+
const mapActionSpec = (spec) => ({
|
|
508
|
+
id: spec.id,
|
|
509
|
+
type: spec.type ?? "",
|
|
510
|
+
name: spec.name,
|
|
511
|
+
slug: spec.slug,
|
|
512
|
+
schema: spec.parameters?.schema,
|
|
513
|
+
});
|
|
514
|
+
/** Every action spec of a service spec (`create`/`update`/`delete`/`custom`), mapped. The caller
|
|
515
|
+
* filters by `type` — `custom` for run-an-action, `delete` for deprovision, `create` for provision. */
|
|
516
|
+
export async function serviceActionSpecs(np, serviceSpecId) {
|
|
517
|
+
const page = await np
|
|
518
|
+
.get(`/service_specification/${serviceSpecId}/action_specification`)
|
|
519
|
+
.catch(() => ({ results: [] }));
|
|
520
|
+
return (page.results ?? []).map(mapActionSpec);
|
|
521
|
+
}
|
|
522
|
+
/** The "create" action spec drives provisioning; its `parameters.schema` is the input form. */
|
|
523
|
+
export async function serviceCreateActionSpec(np, serviceSpecId) {
|
|
524
|
+
return (await serviceActionSpecs(np, serviceSpecId)).find((spec) => spec.type === "create");
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* `POST /service` — creates the record in `pending`. Never sends `status` (the backend sets it).
|
|
528
|
+
* Provisioning does NOT start until `createServiceAction` enqueues the create action.
|
|
529
|
+
*/
|
|
530
|
+
export async function provisionService(np, input) {
|
|
531
|
+
return np.post("/service", {
|
|
532
|
+
name: input.name,
|
|
533
|
+
specification_id: input.specification_id,
|
|
534
|
+
entity_nrn: input.entity_nrn,
|
|
535
|
+
linkable_to: input.linkable_to ?? [input.entity_nrn],
|
|
536
|
+
dimensions: input.dimensions ?? {},
|
|
537
|
+
selectors: { imported: false },
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
/** `POST /service/{id}/action` — enqueue an action (the create action provisions). */
|
|
541
|
+
export async function createServiceAction(np, serviceId, action) {
|
|
542
|
+
return np.post(`/service/${serviceId}/action`, {
|
|
543
|
+
name: action.name,
|
|
544
|
+
specification_id: action.specification_id,
|
|
545
|
+
parameters: action.parameters ?? {},
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
export async function getService(np, serviceId) {
|
|
549
|
+
return np.get(`/service/${serviceId}`);
|
|
550
|
+
}
|
|
551
|
+
/** The link spec(s) that apply to a service spec — the OpenAPI maps them directly. Each carries
|
|
552
|
+
* `use_default_actions`, which decides the linking flow (see `linkService`). */
|
|
553
|
+
export async function linkSpecsForService(np, serviceSpecId) {
|
|
554
|
+
const page = await np
|
|
555
|
+
.get(`/service_specification/${serviceSpecId}/link_specification`)
|
|
556
|
+
.catch(() => ({ results: [] }));
|
|
557
|
+
return (page.results ?? []).map((spec) => ({
|
|
558
|
+
id: spec.id,
|
|
559
|
+
name: spec.name ?? spec.id,
|
|
560
|
+
use_default_actions: spec.use_default_actions ?? false,
|
|
561
|
+
}));
|
|
562
|
+
}
|
|
563
|
+
/** Every action spec of a link spec — the caller filters by `type` (`custom`/`delete`/`create`). */
|
|
564
|
+
export async function linkActionSpecs(np, linkSpecId) {
|
|
565
|
+
const page = await np
|
|
566
|
+
.get(`/link_specification/${linkSpecId}/action_specification`)
|
|
567
|
+
.catch(() => ({ results: [] }));
|
|
568
|
+
return (page.results ?? []).map(mapActionSpec);
|
|
569
|
+
}
|
|
570
|
+
/** The create action spec of a LINK spec (only present when `use_default_actions` is true). */
|
|
571
|
+
export async function linkCreateActionSpec(np, linkSpecId) {
|
|
572
|
+
return (await linkActionSpecs(np, linkSpecId)).find((spec) => spec.type === "create");
|
|
573
|
+
}
|
|
574
|
+
/** `POST /link`. `status` is the SQL landmine: it is DERIVED from the link spec's
|
|
575
|
+
* `use_default_actions`, never passed in. Caller passes the resolved spec; this never takes a free
|
|
576
|
+
* `status`. */
|
|
577
|
+
export async function createLink(np, input) {
|
|
578
|
+
const body = {
|
|
579
|
+
name: input.name,
|
|
580
|
+
service_id: input.service_id,
|
|
581
|
+
entity_nrn: input.entity_nrn,
|
|
582
|
+
specification_id: input.specification_id,
|
|
583
|
+
dimensions: input.dimensions ?? {},
|
|
584
|
+
};
|
|
585
|
+
// use_default_actions=false → no agent exists, so `active` must be set or the link is stuck
|
|
586
|
+
// `pending` forever. use_default_actions=true → OMIT status; a follow-up create-action provisions
|
|
587
|
+
// credentials. Sending `active` there yields an active-but-credential-less, unrecoverable link.
|
|
588
|
+
if (input.activateImmediately)
|
|
589
|
+
body.status = "active";
|
|
590
|
+
return np.post("/link", body);
|
|
591
|
+
}
|
|
592
|
+
export async function createLinkAction(np, linkId, action) {
|
|
593
|
+
return np.post(`/link/${linkId}/action`, {
|
|
594
|
+
name: action.name,
|
|
595
|
+
specification_id: action.specification_id,
|
|
596
|
+
parameters: action.parameters ?? {},
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
export async function getLink(np, linkId) {
|
|
600
|
+
return np.get(`/link/${linkId}`);
|
|
601
|
+
}
|
|
602
|
+
/** Existing dependency services on an app (core API) — for convergence: a retried provision must
|
|
603
|
+
* reuse a same-name service rather than spin up a second (cost-bearing) one. */
|
|
604
|
+
export async function listAppServices(np, nrn) {
|
|
605
|
+
const page = await np
|
|
606
|
+
.get("/service", { nrn, type: "dependency", limit: 100 })
|
|
607
|
+
.catch(() => ({ results: [] }));
|
|
608
|
+
return (page.results ?? []).map((service) => ({
|
|
609
|
+
id: service.id,
|
|
610
|
+
name: service.name ?? service.id,
|
|
611
|
+
status: service.status ?? "",
|
|
612
|
+
specification_id: service.specification_id,
|
|
613
|
+
}));
|
|
614
|
+
}
|
|
615
|
+
/** Existing links on an app (core API) — for convergence: a retried link must reuse an existing one
|
|
616
|
+
* for the same service+target rather than create a duplicate; also feeds the link picker (name) and
|
|
617
|
+
* the link-action / link-delete flows (specification_id → the link spec's action specs). */
|
|
618
|
+
export async function listLinks(np, nrn) {
|
|
619
|
+
const page = await np.get("/link", { nrn }).catch(() => ({ results: [] }));
|
|
620
|
+
return (page.results ?? []).map((link) => ({
|
|
621
|
+
id: link.id,
|
|
622
|
+
name: link.name ?? link.id,
|
|
623
|
+
status: link.status ?? "",
|
|
624
|
+
service_id: link.service_id,
|
|
625
|
+
entity_nrn: link.entity_nrn,
|
|
626
|
+
specification_id: link.specification_id,
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
/** Poll one action of a service — `pending` → `in_progress` → `success`|`failed`. */
|
|
630
|
+
export async function getServiceAction(np, serviceId, actionId) {
|
|
631
|
+
return np.get(`/service/${serviceId}/action/${actionId}`);
|
|
632
|
+
}
|
|
633
|
+
/** Poll one action of a link. */
|
|
634
|
+
export async function getLinkAction(np, linkId, actionId) {
|
|
635
|
+
return np.get(`/link/${linkId}/action/${actionId}`);
|
|
636
|
+
}
|
|
637
|
+
/** `DELETE /service/{id}`. A `failed` service (no provisioned resources) deletes directly; an active
|
|
638
|
+
* one needs `force` OR a prior delete-action that deprovisions — the tool decides which path. */
|
|
639
|
+
export async function deleteService(np, serviceId, force = false) {
|
|
640
|
+
await np.del(`/service/${serviceId}`, force ? { force: true } : undefined);
|
|
641
|
+
}
|
|
642
|
+
/** `DELETE /link/{id}`. `force` skips the deprovisioning delete-action (orphans credentials), so the
|
|
643
|
+
* tool only forces when there's no delete-action to run; otherwise it runs the action first. */
|
|
644
|
+
export async function deleteLink(np, linkId, force = false) {
|
|
645
|
+
await np.del(`/link/${linkId}`, force ? { force: true } : undefined);
|
|
646
|
+
}
|
|
647
|
+
/** `DELETE /parameter/{id}` — removes a whole parameter definition (and all its values). */
|
|
648
|
+
export async function deleteParameter(np, parameterId) {
|
|
649
|
+
await np.del(`/parameter/${parameterId}`);
|
|
650
|
+
}
|
package/dist/tool-names.js
CHANGED
|
@@ -21,6 +21,13 @@ export const TOOL = {
|
|
|
21
21
|
applicationReleaseCreate: "application_release_create",
|
|
22
22
|
applicationScopeCreate: "application_scope_create",
|
|
23
23
|
applicationServiceList: "application_service_list",
|
|
24
|
+
applicationServiceCreate: "application_service_create",
|
|
25
|
+
applicationServiceUpdate: "application_service_update",
|
|
26
|
+
applicationServiceDelete: "application_service_delete",
|
|
27
|
+
applicationLinkCreate: "application_link_create",
|
|
28
|
+
applicationLinkUpdate: "application_link_update",
|
|
29
|
+
applicationLinkDelete: "application_link_delete",
|
|
30
|
+
applicationParameterDelete: "application_parameter_delete",
|
|
24
31
|
applicationApprovalList: "application_approval_list",
|
|
25
32
|
organizationGet: "organization_get",
|
|
26
33
|
playbookGet: "playbook_get",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { translate } from "../i18n.js";
|
|
2
|
+
import { linkLine, next, table } from "../md.js";
|
|
3
|
+
import { fail, reply } from "../tool.js";
|
|
4
|
+
import { delays, sleep } from "./shared.js";
|
|
5
|
+
const slug = (text) => text
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
8
|
+
.replace(/^-+|-+$/g, "");
|
|
9
|
+
const actionLabel = (spec) => spec.name ?? spec.slug ?? spec.id;
|
|
10
|
+
/**
|
|
11
|
+
* The run-a-custom-action flow shared by `application_service_update` and `application_link_update`:
|
|
12
|
+
* pick which action → fill its parameters (form-first; nothing runs without `run:true`) → POST the
|
|
13
|
+
* action → poll it once → report. A custom action is an explicit operation (it is NOT idempotent —
|
|
14
|
+
* running a DML twice runs it twice), so the safety here is the form gate, not convergence.
|
|
15
|
+
*/
|
|
16
|
+
export async function runActionFlow(config, args) {
|
|
17
|
+
const { entity, entityName, specs, dashboard } = config;
|
|
18
|
+
if (specs.length === 0) {
|
|
19
|
+
return fail(translate("runAction.noActions", { name: entityName }) + linkLine(translate("md.dashboard"), dashboard));
|
|
20
|
+
}
|
|
21
|
+
// Match the requested action by name/slug; none or ambiguous → list the runnable actions.
|
|
22
|
+
const wanted = args.action?.toLowerCase();
|
|
23
|
+
const matched = wanted
|
|
24
|
+
? specs.filter((spec) => actionLabel(spec).toLowerCase().includes(wanted) ||
|
|
25
|
+
(spec.slug ?? "").toLowerCase() === wanted ||
|
|
26
|
+
spec.id === args.action)
|
|
27
|
+
: [];
|
|
28
|
+
if (matched.length !== 1) {
|
|
29
|
+
const list = matched.length > 1 ? matched : specs;
|
|
30
|
+
const heading = translate(matched.length > 1 ? "runAction.matchAmbiguous" : "runAction.pickAction", {
|
|
31
|
+
name: entityName,
|
|
32
|
+
});
|
|
33
|
+
const md = `${heading}\n\n${table([translate("header.name"), translate("runAction.paramsHeader")], list.map((spec) => [
|
|
34
|
+
actionLabel(spec),
|
|
35
|
+
(spec.schema?.required ?? []).join(", ") || "—",
|
|
36
|
+
]))}${next(translate("runAction.pickHint"))}`;
|
|
37
|
+
return reply(md, {
|
|
38
|
+
mode: "pick-action",
|
|
39
|
+
entity,
|
|
40
|
+
tool: config.tool,
|
|
41
|
+
app: config.appRef,
|
|
42
|
+
app_name: config.appName,
|
|
43
|
+
target: { id: config.entityId, name: entityName, status: config.entityStatus },
|
|
44
|
+
actions: list.map((spec) => ({ id: spec.id, name: actionLabel(spec), slug: spec.slug })),
|
|
45
|
+
dashboard,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const spec = matched[0];
|
|
49
|
+
// Form-first: no explicit run OR a required parameter still missing → return the form.
|
|
50
|
+
const requiredKeys = spec.schema?.required ?? [];
|
|
51
|
+
const missing = requiredKeys.filter((key) => args.parameters?.[key] === undefined);
|
|
52
|
+
if (!args.run || missing.length > 0) {
|
|
53
|
+
return reply(translate("runAction.form", { action: actionLabel(spec), name: entityName }) +
|
|
54
|
+
next(translate("runAction.formHint")), {
|
|
55
|
+
mode: "form",
|
|
56
|
+
entity,
|
|
57
|
+
tool: config.tool,
|
|
58
|
+
app: config.appRef,
|
|
59
|
+
app_name: config.appName,
|
|
60
|
+
target: { id: config.entityId, name: entityName, status: config.entityStatus },
|
|
61
|
+
action: { id: spec.id, name: actionLabel(spec), slug: spec.slug },
|
|
62
|
+
parameters_schema: spec.schema ?? null,
|
|
63
|
+
dashboard,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// ---- RUN THE ACTION ----
|
|
67
|
+
const fired = await config.run({
|
|
68
|
+
name: spec.slug ?? slug(actionLabel(spec)),
|
|
69
|
+
specification_id: spec.id,
|
|
70
|
+
parameters: args.parameters ?? {},
|
|
71
|
+
});
|
|
72
|
+
await sleep(delays.appPoll);
|
|
73
|
+
const action = await config
|
|
74
|
+
.poll(fired.id)
|
|
75
|
+
.catch(() => ({ id: fired.id, status: fired.status ?? "pending" }));
|
|
76
|
+
const md = `${translate("runAction.running", { action: actionLabel(spec), name: entityName, status: action.status })}` +
|
|
77
|
+
next(translate("runAction.ranHint")) +
|
|
78
|
+
linkLine(translate("md.dashboard"), dashboard);
|
|
79
|
+
return reply(md, {
|
|
80
|
+
mode: "progress",
|
|
81
|
+
entity,
|
|
82
|
+
tool: config.tool,
|
|
83
|
+
app: config.appRef,
|
|
84
|
+
app_name: config.appName,
|
|
85
|
+
target: { id: config.entityId, name: entityName, status: config.entityStatus },
|
|
86
|
+
action: {
|
|
87
|
+
id: action.id,
|
|
88
|
+
name: actionLabel(spec),
|
|
89
|
+
status: action.status,
|
|
90
|
+
results: action.results ?? null,
|
|
91
|
+
},
|
|
92
|
+
dashboard,
|
|
93
|
+
});
|
|
94
|
+
}
|
package/dist/tools/approvals.js
CHANGED
|
@@ -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, dashboardLink, glyph, linkLine, next, table } from "../md.js";
|
|
4
4
|
import { cancelApproval, executeApproval, isSafeApprovalId, listApprovals } from "../np/journey.js";
|
|
5
5
|
import { defineTool, fail, reply } from "../tool.js";
|
|
@@ -52,7 +52,7 @@ export const approvalsTool = defineTool({
|
|
|
52
52
|
return reply(translate("approvals.none", { app: app.name }), { app: `#${app.id}`, approvals: [] });
|
|
53
53
|
}
|
|
54
54
|
const markdown = [
|
|
55
|
-
|
|
55
|
+
plural(approvals.length, "approvals.title.one", "approvals.title.many", { app: app.name }),
|
|
56
56
|
"",
|
|
57
57
|
table([
|
|
58
58
|
translate("header.id"),
|
package/dist/tools/builds.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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, shortCommit, table } from "../md.js";
|
|
4
|
-
import { listAssets, listBuilds, listReleases } from "../np/journey.js";
|
|
4
|
+
import { getBuild, listAssets, listBuilds, listReleases } from "../np/journey.js";
|
|
5
5
|
import { defineTool, reply } from "../tool.js";
|
|
6
6
|
import { TOOL } from "../tool-names.js";
|
|
7
7
|
import { appArg, offsetArg, pageOf, requireApp } from "./shared.js";
|
|
@@ -33,10 +33,17 @@ export const buildsTool = defineTool({
|
|
|
33
33
|
return resolved.out;
|
|
34
34
|
const app = resolved.app;
|
|
35
35
|
if (args.build) {
|
|
36
|
-
|
|
36
|
+
// Fetch the build alongside its assets so the asset view shows the real status/commit and
|
|
37
|
+
// a no-assets message that fits the build's state (running vs failed vs simply none).
|
|
38
|
+
const [build, assets] = await Promise.all([
|
|
39
|
+
getBuild(context.np, args.build),
|
|
40
|
+
listAssets(context.np, args.build),
|
|
41
|
+
]);
|
|
42
|
+
const buildData = build ?? { id: args.build, status: "" };
|
|
37
43
|
if (assets.length === 0) {
|
|
38
|
-
return reply(
|
|
44
|
+
return reply(noAssetsText(build?.status, args.build), {
|
|
39
45
|
build_id: args.build,
|
|
46
|
+
build: buildData,
|
|
40
47
|
assets: [],
|
|
41
48
|
});
|
|
42
49
|
}
|
|
@@ -46,7 +53,7 @@ export const buildsTool = defineTool({
|
|
|
46
53
|
table([translate("header.asset"), translate("header.type"), translate("header.platform")], assets.map((asset) => [asset.name, asset.type, asset.platform ?? ""])),
|
|
47
54
|
next(translate("builds.deployHint", { build: args.build })),
|
|
48
55
|
].join("\n");
|
|
49
|
-
return reply(markdown, { build_id: args.build, assets });
|
|
56
|
+
return reply(markdown, { build_id: args.build, build: buildData, assets });
|
|
50
57
|
}
|
|
51
58
|
const [builds, releases] = await Promise.all([
|
|
52
59
|
listBuilds(context.np, app.id, { limit: args.limit ?? 10, commit: args.commit, offset: args.offset }),
|
|
@@ -61,7 +68,7 @@ export const buildsTool = defineTool({
|
|
|
61
68
|
const releasedBuildIds = new Set(releases.map((release) => release.build_id).filter(Boolean));
|
|
62
69
|
const releaseByBuild = new Map(releases.map((release) => [release.build_id, release]));
|
|
63
70
|
const markdown = [
|
|
64
|
-
|
|
71
|
+
plural(builds.length, "builds.title.one", "builds.title.many", { app: app.name }),
|
|
65
72
|
"",
|
|
66
73
|
table([
|
|
67
74
|
translate("header.id"),
|
|
@@ -92,6 +99,16 @@ export const buildsTool = defineTool({
|
|
|
92
99
|
});
|
|
93
100
|
},
|
|
94
101
|
});
|
|
102
|
+
/** No-assets message keyed to the build's state — "may still be running" only fits a build that
|
|
103
|
+
* actually still is; a failed or finished one needs the truth. */
|
|
104
|
+
function noAssetsText(status, build) {
|
|
105
|
+
if (status === "pending" || status === "in_progress") {
|
|
106
|
+
return translate("builds.noAssetsRunning", { build });
|
|
107
|
+
}
|
|
108
|
+
if (status === "failed")
|
|
109
|
+
return translate("builds.noAssetsFailed", { build });
|
|
110
|
+
return translate("builds.noAssets", { build });
|
|
111
|
+
}
|
|
95
112
|
function buildsHint(builds, releasedBuildIds) {
|
|
96
113
|
const newestSuccessful = builds.find((build) => build.status === "successful");
|
|
97
114
|
if (newestSuccessful && !releasedBuildIds.has(newestSuccessful.id)) {
|
|
@@ -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
|
+
});
|