@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
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
|
+
}
|
|
@@ -12,7 +12,7 @@ You run in the developer's own environment, so fuse the local repo with platform
|
|
|
12
12
|
|
|
13
13
|
Every tool accepts \`language\`: ALWAYS set it to the language the user is conversing in (ISO code, e.g. "es", "en") — answers come back in the user's language.
|
|
14
14
|
|
|
15
|
-
Most tools render an interactive panel in clients that support it —
|
|
15
|
+
Most tools render an interactive panel in clients that support it — apps, status, builds, releases, deployments, logs, metrics, parameters and approvals all appear as live UI. **When a tool's panel renders, that panel IS the answer: do not reproduce its data in your text reply.** The user already sees every row, status and value. NEVER print a markdown table of the same rows, re-list the items, or restate per-row status — duplicating the panel in text is the single most common mistake. Reply with AT MOST one short sentence — the one key takeaway or the next step — or nothing at all. A one-line insight the panel doesn't itself show is fine ("builds 3 and 5 were never released"); re-rendering the list as a table is not.
|
|
16
16
|
|
|
17
17
|
When the user wants to create, scaffold, set up or import an application, call \`application_create\` right away — pass a name if they gave one, otherwise no arguments. Its panel is an interactive FORM that collects the namespace, template and repository itself. Do NOT gather those details in conversation first, and while that form is on screen do NOT ask the user clarifying questions or use any question-asking tool for fields the form already covers (namespace, template, new-vs-import repository, monorepo path) — the form is the input surface. Just call \`application_create\` and let it drive; react only to what it reports back.
|
|
18
18
|
|
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)) {
|