@nullplatform/mcp 0.1.6 → 0.1.8
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 +599 -24
- 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-app.js +37 -9
- 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 +84 -0
- package/widgets-dist/approvals.html +261 -57
- package/widgets-dist/builds.html +261 -57
- package/widgets-dist/create-app.html +257 -53
- package/widgets-dist/deployments.html +251 -47
- package/widgets-dist/find-apps.html +257 -53
- 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 +257 -53
- package/widgets-dist/params.html +261 -57
- 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 +256 -52
package/dist/np/client.js
CHANGED
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)) {
|
package/dist/tools/create-app.js
CHANGED
|
@@ -48,6 +48,10 @@ export const createAppTool = defineTool({
|
|
|
48
48
|
errorKey: "createApp.errorLabel",
|
|
49
49
|
inputSchema: {
|
|
50
50
|
name: z.string().optional().describe("App name (default: the repo name)"),
|
|
51
|
+
account: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe("Account name to place the app under — narrows the namespace choice to that account (a namespace name can repeat across accounts). Pick the best-fitting account from context when it isn't given."),
|
|
51
55
|
namespace: z.string().optional().describe("Namespace name (prefer namespace_id when known)"),
|
|
52
56
|
namespace_id: z.number().optional().describe("Namespace id to create the app in"),
|
|
53
57
|
repository_url: z
|
|
@@ -104,26 +108,39 @@ export const createAppTool = defineTool({
|
|
|
104
108
|
?.replace(/\.git$/, "");
|
|
105
109
|
if (!name)
|
|
106
110
|
return fail(translate("createApp.noName", { url: repoUrl }));
|
|
107
|
-
// Resolve the
|
|
108
|
-
//
|
|
111
|
+
// Resolve where the app lives — an (account, namespace) pair. Narrow to the named account first
|
|
112
|
+
// (a namespace name can repeat across accounts), then match/auto-pick the namespace inside it.
|
|
113
|
+
// If it still can't be pinned down and there's a real choice, fall back to the form (a picker)
|
|
114
|
+
// rather than a dead-end ask.
|
|
109
115
|
const skeleton = await context.org.getSkeleton();
|
|
116
|
+
const accountQuery = args.account?.toLowerCase();
|
|
117
|
+
const candidates = accountQuery
|
|
118
|
+
? skeleton.namespaces.filter((candidate) => (candidate.account_name ?? "").toLowerCase().includes(accountQuery))
|
|
119
|
+
: skeleton.namespaces;
|
|
120
|
+
if (accountQuery && candidates.length === 0) {
|
|
121
|
+
const accounts = [
|
|
122
|
+
...new Set(skeleton.namespaces.map((candidate) => candidate.account_name).filter(Boolean)),
|
|
123
|
+
];
|
|
124
|
+
return fail(translate("createApp.noAccountMatch", { account: args.account ?? "", names: accounts.join(", ") }));
|
|
125
|
+
}
|
|
110
126
|
let namespace = args.namespace_id
|
|
111
|
-
?
|
|
127
|
+
? candidates.find((candidate) => candidate.id === args.namespace_id)
|
|
112
128
|
: undefined;
|
|
113
129
|
if (!namespace && args.namespace) {
|
|
114
130
|
const query = args.namespace.toLowerCase();
|
|
115
|
-
const matches =
|
|
131
|
+
const matches = candidates.filter((candidate) => candidate.name.toLowerCase().includes(query));
|
|
116
132
|
if (matches.length === 1)
|
|
117
133
|
namespace = matches[0];
|
|
118
134
|
else if (matches.length === 0) {
|
|
119
135
|
return fail(translate("createApp.noNamespaceMatch", {
|
|
120
136
|
namespace: args.namespace,
|
|
121
|
-
names:
|
|
137
|
+
names: candidates.map((candidate) => candidate.name).join(", "),
|
|
122
138
|
}));
|
|
123
139
|
}
|
|
124
140
|
}
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
// One namespace in scope (the whole org, or the chosen account) → auto-pick it.
|
|
142
|
+
if (!namespace && candidates.length === 1)
|
|
143
|
+
namespace = candidates[0];
|
|
127
144
|
if (!namespace) {
|
|
128
145
|
if (skeleton.namespaces.length === 0)
|
|
129
146
|
return fail(translate("createApp.noNamespaces"));
|
|
@@ -174,10 +191,21 @@ export const createAppTool = defineTool({
|
|
|
174
191
|
.slice(-3)
|
|
175
192
|
.map((entry) => `- ${entry.message ?? JSON.stringify(entry)}`)
|
|
176
193
|
.join("\n");
|
|
177
|
-
|
|
194
|
+
// A new repo scaffolded from a template is a fresh REMOTE repo the developer has never cloned —
|
|
195
|
+
// the next move is to pull it into a folder and build there. An import is already local, so its
|
|
196
|
+
// next move is just to push. The two paths get different guidance.
|
|
197
|
+
const scaffolded = args.template_id !== undefined;
|
|
198
|
+
const structured = {
|
|
199
|
+
application: { id: app.id, name, status: app.status, nrn: app.nrn },
|
|
200
|
+
scaffolded,
|
|
201
|
+
repository_url: repoUrl,
|
|
202
|
+
};
|
|
203
|
+
const createdHint = scaffolded
|
|
204
|
+
? translate("createApp.createdHintNew", { repo: repoUrl })
|
|
205
|
+
: translate("createApp.createdHint");
|
|
178
206
|
if (app.status === "active") {
|
|
179
207
|
return reply(`${glyph("active")} ${translate("createApp.created", { name, id: app.id, namespace: namespace.name, repo: repoUrl })}${recentMessages ? `\n${recentMessages}` : ""}` +
|
|
180
|
-
next(
|
|
208
|
+
next(createdHint) +
|
|
181
209
|
dashboard, structured);
|
|
182
210
|
}
|
|
183
211
|
return reply(`${translate("createApp.pending", { name, id: app.id, status: app.status })}${recentMessages ? `\n${recentMessages}` : ""}` +
|