@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.
Files changed (46) hide show
  1. package/dist/i18n.js +206 -22
  2. package/dist/np/client.js +3 -0
  3. package/dist/np/journey.js +236 -24
  4. package/dist/tool-names.js +7 -0
  5. package/dist/tools/action-flow.js +94 -0
  6. package/dist/tools/approvals.js +2 -2
  7. package/dist/tools/builds.js +23 -6
  8. package/dist/tools/create-link.js +163 -0
  9. package/dist/tools/create-service.js +149 -0
  10. package/dist/tools/delete-flow.js +30 -0
  11. package/dist/tools/delete-link.js +95 -0
  12. package/dist/tools/delete-param.js +76 -0
  13. package/dist/tools/delete-service.js +108 -0
  14. package/dist/tools/deployments.js +2 -2
  15. package/dist/tools/index.js +14 -0
  16. package/dist/tools/logs.js +2 -2
  17. package/dist/tools/metrics.js +4 -1
  18. package/dist/tools/overview.js +2 -2
  19. package/dist/tools/params.js +78 -17
  20. package/dist/tools/playbook.js +2 -1
  21. package/dist/tools/releases.js +2 -2
  22. package/dist/tools/services.js +2 -2
  23. package/dist/tools/set-params.js +61 -11
  24. package/dist/tools/shared.js +4 -0
  25. package/dist/tools/update-link.js +87 -0
  26. package/dist/tools/update-service.js +92 -0
  27. package/dist/ui.js +4 -1
  28. package/package.json +3 -1
  29. package/skills/starting-a-new-app/SKILL.md +71 -0
  30. package/widgets-dist/approvals.html +261 -57
  31. package/widgets-dist/builds.html +256 -52
  32. package/widgets-dist/create-app.html +252 -48
  33. package/widgets-dist/deployments.html +251 -47
  34. package/widgets-dist/find-apps.html +251 -47
  35. package/widgets-dist/logs.html +256 -52
  36. package/widgets-dist/manifest.json +16 -13
  37. package/widgets-dist/metrics.html +261 -57
  38. package/widgets-dist/np-panel.html +256 -52
  39. package/widgets-dist/overview.html +256 -52
  40. package/widgets-dist/params.html +257 -53
  41. package/widgets-dist/releases.html +257 -53
  42. package/widgets-dist/service-action.html +1118 -0
  43. package/widgets-dist/service-create.html +1117 -0
  44. package/widgets-dist/{playbook.html → service-delete.html} +258 -58
  45. package/widgets-dist/service-link.html +1117 -0
  46. package/widgets-dist/services.html +249 -45
@@ -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((build) => ({
33
- id: build.id,
34
- status: build.status,
35
- branch: build.branch,
36
- commit: String(build.commit?.id ?? build.commit_id ?? "") || undefined,
37
- created_at: build.created_at,
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. Agents retry, and re-running "set DATABASE_URL" must not accrete a
237
- * duplicate definition so reuse the existing definition (matched by variable/name at this
238
- * NRN) and just add a value; only POST a new definition when none exists. Reports how many
239
- * were created vs updated.
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
- await np.post(`/parameter/${definitionId}/values`, { values: [{ nrn, value: param.value }] });
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; returns undefined when unavailable. */
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((parameter) => ({
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
+ }
@@ -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
+ }
@@ -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
- translate("approvals.title", { app: app.name, count: approvals.length }),
55
+ plural(approvals.length, "approvals.title.one", "approvals.title.many", { app: app.name }),
56
56
  "",
57
57
  table([
58
58
  translate("header.id"),
@@ -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
- const assets = await listAssets(context.np, args.build);
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(translate("builds.noAssets", { build: args.build }), {
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
- translate("builds.title", { app: app.name, count: builds.length }),
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
+ });