@kikimoradev/kcloud 0.17.0 → 0.18.0

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 (2) hide show
  1. package/dist/main.js +884 -36
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -795,6 +795,18 @@ const errorCodes = Object.keys({
795
795
  "agent.backup_unavailable": "rule_violation",
796
796
  "agent.backup_without_export": "rule_violation",
797
797
  "agent.backup_in_progress": "conflict",
798
+ "node.machine_missing": "rule_violation",
799
+ "node.operation_running": "conflict",
800
+ "node.operation_not_failed": "conflict",
801
+ "node.size_unavailable": "rule_violation",
802
+ "node.size_not_larger": "rule_violation",
803
+ "node.region_unavailable": "rule_violation",
804
+ "provider.not_found": "not_found",
805
+ "provider.name_taken": "conflict",
806
+ "provider.in_use": "conflict",
807
+ "provider.token_unauthorized": "rule_violation",
808
+ "provider.token_scopes_missing": "rule_violation",
809
+ "provider.unavailable": "rule_violation",
798
810
  "project.last_admin": "conflict",
799
811
  "agent.node_unassigned": "rule_violation",
800
812
  "stream.too_many": "rate_limited",
@@ -1440,6 +1452,83 @@ pageSchema(z.object({
1440
1452
  finishedAt: z.iso.datetime().nullable()
1441
1453
  }).meta({ id: "TaskCost" })).meta({ id: "TaskCostPage" });
1442
1454
  costQuerySchema.extend({ cursor: cursorSchema.optional() });
1455
+ const providerKindSchema = z.enum(["digitalocean"]);
1456
+ const providerAccountNameSchema = z.string().trim().min(1).max(64);
1457
+ const providerTokenSchema = z.string().trim().min(16).max(512).regex(/^[\x21-\x7e]+$/u);
1458
+ const providerRegionSlugSchema = z.string().trim().regex(/^[a-z]{3}[0-9]$/u);
1459
+ const providerSizeSlugSchema = z.string().trim().regex(/^[a-z0-9][a-z0-9-]{0,63}$/u);
1460
+ const providerTokenScopes = [
1461
+ "regions:read",
1462
+ "sizes:read",
1463
+ "image:read",
1464
+ "vpc:read",
1465
+ "actions:read",
1466
+ "droplet:create",
1467
+ "droplet:read",
1468
+ "droplet:update",
1469
+ "droplet:delete",
1470
+ "block_storage:create",
1471
+ "block_storage:read",
1472
+ "block_storage:delete",
1473
+ "block_storage_action:create",
1474
+ "firewall:create",
1475
+ "firewall:read",
1476
+ "firewall:delete",
1477
+ "tag:create",
1478
+ "tag:read",
1479
+ "tag:delete"
1480
+ ];
1481
+ const providerTokenScopeSchema = z.enum(providerTokenScopes).meta({ id: "ProviderTokenScope" });
1482
+ const providerVerificationSchema = z.object({
1483
+ checkedAt: z.iso.datetime(),
1484
+ missingScopes: z.array(providerTokenScopeSchema),
1485
+ failure: z.string().nullable()
1486
+ }).meta({ id: "ProviderVerification" });
1487
+ const providerAccountSchema = z.object({
1488
+ id: idSchema,
1489
+ kind: providerKindSchema,
1490
+ name: z.string(),
1491
+ defaultRegion: z.string(),
1492
+ revision: z.int().nonnegative(),
1493
+ verification: providerVerificationSchema,
1494
+ nodeCount: z.int().nonnegative(),
1495
+ createdAt: z.iso.datetime(),
1496
+ updatedAt: z.iso.datetime()
1497
+ }).meta({ id: "ProviderAccount" });
1498
+ z.object({ items: z.array(providerAccountSchema) }).meta({ id: "ProviderAccountList" });
1499
+ const createProviderAccountBodySchema = z.object({
1500
+ kind: providerKindSchema,
1501
+ name: providerAccountNameSchema,
1502
+ token: providerTokenSchema,
1503
+ defaultRegion: providerRegionSlugSchema
1504
+ }).meta({ id: "CreateProviderAccountBody" });
1505
+ z.object({
1506
+ revision: z.int().nonnegative(),
1507
+ name: providerAccountNameSchema.optional(),
1508
+ token: providerTokenSchema.optional(),
1509
+ defaultRegion: providerRegionSlugSchema.optional()
1510
+ }).meta({ id: "UpdateProviderAccountBody" });
1511
+ const providerRegionSchema = z.object({
1512
+ slug: z.string(),
1513
+ name: z.string(),
1514
+ available: z.boolean()
1515
+ }).meta({ id: "ProviderRegion" });
1516
+ const providerSizeSchema = z.object({
1517
+ slug: z.string(),
1518
+ vcpus: z.int().positive(),
1519
+ memoryMb: z.int().positive(),
1520
+ diskGb: z.int().positive(),
1521
+ priceMonthly: z.number().nonnegative(),
1522
+ regions: z.array(z.string())
1523
+ }).meta({ id: "ProviderSize" });
1524
+ z.object({
1525
+ regions: z.array(providerRegionSchema),
1526
+ sizes: z.array(providerSizeSchema)
1527
+ }).meta({ id: "ProviderCatalog" });
1528
+ z.object({
1529
+ organizationId: idSchema,
1530
+ accountId: idSchema
1531
+ });
1443
1532
  z.enum(["monitor", "executor"]);
1444
1533
  const sessionSlotCountSchema = z.int().min(1).max(64);
1445
1534
  const sessionQueueEntrySchema = z.object({
@@ -1482,7 +1571,8 @@ const nodeStateSchema = z.enum([
1482
1571
  "online",
1483
1572
  "offline"
1484
1573
  ]);
1485
- const nodeProviderSchema = z.enum(["self_hosted"]);
1574
+ const nodeProviders = ["self_hosted", "digitalocean"];
1575
+ const nodeProviderSchema = z.enum(nodeProviders);
1486
1576
  const nodeNameSchema = z.string().trim().regex(slugPattern);
1487
1577
  const nodeHostSchema = z.object({
1488
1578
  cpus: z.number().nonnegative(),
@@ -1557,6 +1647,52 @@ const nodePoolsSchema = z.object({
1557
1647
  monitor: nodePoolSchema,
1558
1648
  executor: nodePoolSchema
1559
1649
  }).meta({ id: "NodePools" });
1650
+ const nodeVolumeGbSchema = z.int().min(10).max(4096);
1651
+ const nodeMachineSchema = z.object({
1652
+ account: z.object({
1653
+ id: idSchema,
1654
+ name: z.string().nullable()
1655
+ }),
1656
+ region: z.string(),
1657
+ size: z.string(),
1658
+ volumeGb: z.int().positive(),
1659
+ dropletId: z.int().positive().nullable(),
1660
+ publicIpv4: z.string().nullable()
1661
+ }).meta({ id: "NodeMachine" });
1662
+ const nodeOperationKindSchema = z.enum([
1663
+ "create",
1664
+ "resize",
1665
+ "delete"
1666
+ ]);
1667
+ const nodeOperationStateSchema = z.enum(["running", "failed"]);
1668
+ const nodeOperationStepSchema = z.enum([
1669
+ "tag",
1670
+ "firewall",
1671
+ "volume",
1672
+ "droplet",
1673
+ "boot",
1674
+ "installing",
1675
+ "power_off",
1676
+ "resize_droplet",
1677
+ "resize_volume",
1678
+ "power_on",
1679
+ "delete_droplet",
1680
+ "delete_volume",
1681
+ "delete_firewall",
1682
+ "delete_tag"
1683
+ ]);
1684
+ const nodeOperationSchema = z.object({
1685
+ kind: nodeOperationKindSchema,
1686
+ state: nodeOperationStateSchema,
1687
+ step: nodeOperationStepSchema.nullable(),
1688
+ error: z.string().nullable(),
1689
+ target: z.object({
1690
+ size: z.string(),
1691
+ volumeGb: z.int().positive()
1692
+ }).nullable(),
1693
+ startedAt: z.iso.datetime(),
1694
+ updatedAt: z.iso.datetime()
1695
+ }).meta({ id: "NodeOperation" });
1560
1696
  const nodeSchema = z.object({
1561
1697
  id: idSchema,
1562
1698
  name: nodeNameSchema,
@@ -1574,6 +1710,8 @@ const nodeSchema = z.object({
1574
1710
  lastSeenAt: z.iso.datetime().nullable(),
1575
1711
  offlineSince: z.iso.datetime().nullable(),
1576
1712
  registrationExpiresAt: z.iso.datetime().nullable(),
1713
+ machine: nodeMachineSchema.nullable(),
1714
+ operation: nodeOperationSchema.nullable(),
1577
1715
  createdAt: z.iso.datetime()
1578
1716
  }).meta({ id: "Node" });
1579
1717
  const nodeUpdateSchema = z.object({
@@ -1656,15 +1794,34 @@ z.object({
1656
1794
  revision: z.int().nonnegative(),
1657
1795
  split: nodeSplitSchema.nullable()
1658
1796
  }).meta({ id: "UpdateNodeSplitBody" });
1659
- const createNodeBodySchema = z.object({ name: nodeNameSchema }).meta({ id: "CreateNodeBody" });
1797
+ const nodeMachineRequestSchema = z.object({
1798
+ accountId: idSchema,
1799
+ region: providerRegionSlugSchema,
1800
+ size: providerSizeSlugSchema,
1801
+ volumeGb: nodeVolumeGbSchema
1802
+ }).meta({ id: "NodeMachineRequest" });
1803
+ const createNodeBodySchema = z.object({
1804
+ name: nodeNameSchema,
1805
+ machine: nodeMachineRequestSchema.optional()
1806
+ }).meta({ id: "CreateNodeBody" });
1807
+ const resizeNodeBodySchema = z.object({
1808
+ revision: z.int().nonnegative(),
1809
+ size: providerSizeSlugSchema,
1810
+ volumeGb: nodeVolumeGbSchema
1811
+ }).meta({ id: "ResizeNodeBody" });
1812
+ z.object({ revision: z.int().nonnegative() }).meta({ id: "RetryNodeOperationBody" });
1660
1813
  const nodeRegistrationSchema = z.object({
1661
1814
  token: z.string(),
1662
1815
  expiresAt: z.iso.datetime()
1663
1816
  }).meta({ id: "NodeRegistration" });
1817
+ z.object({
1818
+ node: nodeSchema,
1819
+ registration: nodeRegistrationSchema.nullable()
1820
+ }).meta({ id: "CreatedNode" });
1664
1821
  z.object({
1665
1822
  node: nodeSchema,
1666
1823
  registration: nodeRegistrationSchema
1667
- }).meta({ id: "CreatedNode" }).meta({ id: "RenewedNodeRegistration" });
1824
+ }).meta({ id: "RenewedNodeRegistration" });
1668
1825
  z.object({
1669
1826
  token: z.string().min(1).max(256),
1670
1827
  version: z.string().min(1).max(128),
@@ -1672,6 +1829,16 @@ z.object({
1672
1829
  host: nodeHostSchema.nullable(),
1673
1830
  capabilities: nodeCapabilitiesSchema
1674
1831
  }).meta({ id: "RegisterNodeBody" });
1832
+ z.object({ token: z.string().min(1).max(256) }).meta({ id: "NodeInstallationBody" });
1833
+ z.object({
1834
+ platformVersion: z.string(),
1835
+ image: z.string().nullable(),
1836
+ registry: z.object({
1837
+ server: z.string(),
1838
+ username: z.string(),
1839
+ password: z.string()
1840
+ }).nullable()
1841
+ }).meta({ id: "NodeInstallation" });
1675
1842
  z.object({
1676
1843
  nodeId: idSchema,
1677
1844
  credential: z.string()
@@ -1881,6 +2048,18 @@ const auditActionSchema = z.enum([
1881
2048
  "node.registration_renewed",
1882
2049
  "node.deleted",
1883
2050
  "node.split_changed",
2051
+ "node.resize_requested",
2052
+ "node.removal_requested",
2053
+ "node.operation_retried",
2054
+ "node.operation_failed",
2055
+ "node.machine_created",
2056
+ "node.machine_resized",
2057
+ "node.machine_removed",
2058
+ "provider_account.created",
2059
+ "provider_account.updated",
2060
+ "provider_account.token_replaced",
2061
+ "provider_account.verified",
2062
+ "provider_account.deleted",
1884
2063
  "master_key.reencrypted",
1885
2064
  "dead_letter.retried",
1886
2065
  "dead_letter.discarded"
@@ -1901,7 +2080,8 @@ const auditTargetTypeSchema = z.enum([
1901
2080
  "host_operation",
1902
2081
  "volume",
1903
2082
  "queue",
1904
- "node"
2083
+ "node",
2084
+ "provider_account"
1905
2085
  ]);
1906
2086
  const auditEntrySchema = z.object({
1907
2087
  id: idSchema,
@@ -2869,7 +3049,8 @@ const masterKeyVersionSchema = z.object({
2869
3049
  secrets: z.object({
2870
3050
  credentials: secretCountSchema,
2871
3051
  toolSecrets: secretCountSchema,
2872
- platformSecrets: secretCountSchema
3052
+ platformSecrets: secretCountSchema,
3053
+ providerAccounts: secretCountSchema.default(0)
2873
3054
  }),
2874
3055
  backups: secretCountSchema,
2875
3056
  newestBackupExpiresAt: instantSchema$1.nullable()
@@ -3748,20 +3929,125 @@ const validated = (schema, document, what) => {
3748
3929
  if (!parsed.success) throw new CliError(`That is not ${what}.`, z.prettifyError(parsed.error));
3749
3930
  };
3750
3931
  //#endregion
3932
+ //#region src/commands/providers/shared.ts
3933
+ const providerHints = {
3934
+ ...organizationHints,
3935
+ "auth.forbidden": "Changing a provider account takes an admin of the organization.",
3936
+ "provider.not_found": "kcloud providers list shows the provider accounts.",
3937
+ "provider.name_taken": "Another provider account of the organization has that name.",
3938
+ "provider.in_use": "Delete the nodes created with it first; kcloud nodes list shows the provider of each node.",
3939
+ "provider.token_unauthorized": "DigitalOcean refused the token. Create a new one under API, Tokens in its control panel.",
3940
+ "concurrency.conflict": "Someone changed the account meanwhile; run the command again."
3941
+ };
3942
+ const reasonOf$1 = (error) => {
3943
+ const reason = error.details.reason;
3944
+ return typeof reason === "string" && reason !== "" ? `: ${reason}` : "";
3945
+ };
3946
+ const missingScopesOf = (error) => {
3947
+ const scopes = error.details.missingScopes;
3948
+ return typeof scopes === "string" && scopes !== "" ? scopes : "some scopes";
3949
+ };
3950
+ const apiErrorOf$1 = (error) => {
3951
+ if (error instanceof ApiError) return error;
3952
+ return error instanceof Error ? apiErrorOf$1(error.cause) : null;
3953
+ };
3954
+ const explained = (error) => {
3955
+ const failure = apiErrorOf$1(error);
3956
+ switch (failure?.code) {
3957
+ case "provider.token_scopes_missing": return new CliError(failure.message, `The token lacks ${missingScopesOf(failure)}. kcloud providers add --help lists every scope it needs.`, { cause: failure });
3958
+ case "provider.unavailable": return new CliError(failure.message, `DigitalOcean did not answer as expected${reasonOf$1(failure)}. Try again in a moment.`, { cause: failure });
3959
+ default: return error;
3960
+ }
3961
+ };
3962
+ const providerDataOf = (action, answer, hints = providerHints) => {
3963
+ try {
3964
+ return dataOf(action, answer, hints);
3965
+ } catch (error) {
3966
+ throw explained(error);
3967
+ }
3968
+ };
3969
+ const providerDoneWith = (action, answer, hints = providerHints) => {
3970
+ try {
3971
+ doneWith(action, answer, hints);
3972
+ } catch (error) {
3973
+ throw explained(error);
3974
+ }
3975
+ };
3976
+ const providerAccountsOf = async (api, organizationId) => providerDataOf("list the provider accounts", await api.GET("/organizations/{organizationId}/provider-accounts", { params: { path: { organizationId } } })).items;
3977
+ const providerAccountOf = async (api, organizationId, accountId) => providerDataOf("describe the provider account", await api.GET("/organizations/{organizationId}/provider-accounts/{accountId}", { params: { path: {
3978
+ organizationId,
3979
+ accountId
3980
+ } } }));
3981
+ const resolveProviderAccount = async (scope, reference) => {
3982
+ const organization = await scope.organization();
3983
+ if (isId(reference)) return providerAccountOf(scope.api, organization.id, reference);
3984
+ const accounts = await providerAccountsOf(scope.api, organization.id);
3985
+ const found = accounts.find((account) => account.name === reference);
3986
+ if (found === void 0) throw new CliError(`There is no provider account ${reference} in ${organization.slug}.`, accounts.length === 0 ? "kcloud providers add <name> --region <slug> adds one." : `kcloud providers list shows ${accounts.map(({ name }) => name).join(", ")}.`);
3987
+ return found;
3988
+ };
3989
+ const verifiedAccount = async (api, organizationId, accountId) => providerDataOf("verify the provider account", await api.POST("/organizations/{organizationId}/provider-accounts/{accountId}/verification", { params: { path: {
3990
+ organizationId,
3991
+ accountId
3992
+ } } }));
3993
+ const providerCatalogOf = async (api, organizationId, accountId) => providerDataOf("read the regions and sizes of the provider", await api.GET("/organizations/{organizationId}/provider-accounts/{accountId}/catalog", { params: { path: {
3994
+ organizationId,
3995
+ accountId
3996
+ } } }));
3997
+ const catalogInRegion = (catalog, region) => {
3998
+ if (!catalog.regions.some(({ slug }) => slug === region)) throw new CliError(`The account offers no region ${region} for a node.`, `Its regions are ${catalog.regions.map(({ slug }) => slug).join(", ")}.`);
3999
+ return {
4000
+ region,
4001
+ regions: catalog.regions,
4002
+ sizes: catalog.sizes.filter(({ regions }) => regions.includes(region))
4003
+ };
4004
+ };
4005
+ const deleteProviderAccount = async (api, organizationId, accountId) => {
4006
+ providerDoneWith("delete the provider account", await api.DELETE("/organizations/{organizationId}/provider-accounts/{accountId}", { params: { path: {
4007
+ organizationId,
4008
+ accountId
4009
+ } } }));
4010
+ };
4011
+ const verificationText = ({ failure, missingScopes }) => {
4012
+ if (failure === "unauthorized") return "token refused";
4013
+ if (failure !== null) return `not checked: ${failure}`;
4014
+ return missingScopes.length === 0 ? "ok" : `missing ${missingScopes.length} of ${providerTokenScopes.length} scopes`;
4015
+ };
4016
+ const scopeLineWidth = 64;
4017
+ const scopeLines = () => providerTokenScopes.reduce((lines, scope) => {
4018
+ const last = lines.at(-1);
4019
+ if (last !== void 0 && last.length + scope.length + 2 <= scopeLineWidth) {
4020
+ lines[lines.length - 1] = `${last}, ${scope}`;
4021
+ return lines;
4022
+ }
4023
+ return [...lines, scope];
4024
+ }, []);
4025
+ //#endregion
3751
4026
  //#region src/commands/nodes/shared.ts
3752
4027
  const nodeHints = {
3753
4028
  ...organizationHints,
3754
4029
  "node.not_found": "kcloud nodes list shows the nodes of the organization.",
3755
4030
  "node.name_taken": "Another node of the organization has that name.",
3756
4031
  "node.in_use": "Delete the agents placed on it first; kcloud nodes show <node> lists them.",
3757
- "node.registration_used": "The node has joined and holds its own credential; it needs no token."
4032
+ "node.registration_used": "The node has joined and holds its own credential; it needs no token.",
4033
+ "node.machine_missing": "The node runs on a host of its own, which the platform does not resize.",
4034
+ "node.operation_running": "The platform is still working on the node; kcloud nodes show <node> shows the step.",
4035
+ "node.operation_not_failed": "Only a failed operation can be retried; kcloud nodes show <node> shows the operation.",
4036
+ "node.region_unavailable": "kcloud providers catalog <account> lists the regions a node can go to.",
4037
+ "node.size_unavailable": "kcloud providers catalog <account> --region <slug> lists the sizes of a region.",
4038
+ "node.size_not_larger": "A node only grows: name a size with more CPUs or memory, or a larger volume.",
4039
+ "concurrency.conflict": "Someone changed the node meanwhile; run the command again."
4040
+ };
4041
+ const machineHints = {
4042
+ ...providerHints,
4043
+ ...nodeHints,
4044
+ "auth.forbidden": "Creating, resizing and deleting nodes take an admin of the organization."
3758
4045
  };
3759
4046
  const splitHints = {
3760
4047
  ...nodeHints,
3761
4048
  "auth.forbidden": "Splitting a node takes an admin of its organization.",
3762
4049
  "node.host_unknown": "The node reports its CPUs and memory once its service runs; kcloud nodes show <node> shows its state.",
3763
- "node.split_exceeds_host": "The split asks for more than the host has; kcloud nodes split <node> shows the host.",
3764
- "concurrency.conflict": "Someone changed the node meanwhile; run the command again."
4050
+ "node.split_exceeds_host": "The split asks for more than the host has; kcloud nodes split <node> shows the host."
3765
4051
  };
3766
4052
  const nodesOf = async (api, organizationId) => dataOf("list the nodes", await api.GET("/organizations/{organizationId}/nodes", { params: { path: { organizationId } } }), nodeHints).items;
3767
4053
  const namesOf = (nodes) => nodes.map(({ name }) => name).join(", ");
@@ -3786,24 +4072,57 @@ const nodeDetailOf = async (api, { organizationId, nodeId }) => dataOf("describe
3786
4072
  organizationId,
3787
4073
  nodeId
3788
4074
  } } }), nodeHints);
3789
- const createdNode = async (api, organizationId, name) => {
3790
- const body = { name };
4075
+ const createdNode = async (api, organizationId, body) => {
3791
4076
  validated(createNodeBodySchema, body, "a node");
3792
- return dataOf("create the node", await api.POST("/organizations/{organizationId}/nodes", {
4077
+ return providerDataOf("create the node", await api.POST("/organizations/{organizationId}/nodes", {
3793
4078
  params: { path: { organizationId } },
3794
4079
  body
3795
- }), nodeHints);
4080
+ }), machineHints);
3796
4081
  };
3797
4082
  const renewedRegistration = async (api, { organizationId, nodeId }) => dataOf("issue a registration token for the node", await api.POST("/organizations/{organizationId}/nodes/{nodeId}/registration", { params: { path: {
3798
4083
  organizationId,
3799
4084
  nodeId
3800
4085
  } } }), nodeHints);
3801
4086
  const deleteNode = async (api, { organizationId, nodeId }) => {
3802
- doneWith("delete the node", await api.DELETE("/organizations/{organizationId}/nodes/{nodeId}", { params: { path: {
4087
+ const answer = await api.DELETE("/organizations/{organizationId}/nodes/{nodeId}", { params: { path: {
3803
4088
  organizationId,
3804
4089
  nodeId
3805
- } } }), nodeHints);
4090
+ } } });
4091
+ providerDoneWith("delete the node", answer, machineHints);
4092
+ return answer.response.status === 202 ? answer.data ?? null : null;
4093
+ };
4094
+ const resizeTargetOf = (node, { size, volumeGb }) => {
4095
+ const { machine } = node;
4096
+ if (machine === null) throw new CliError(`${node.name} runs on a host of its own, so the platform cannot resize it.`, "Only a node created with --provider digitalocean can be resized.");
4097
+ const target = {
4098
+ size: size ?? machine.size,
4099
+ volumeGb: volumeGb ?? machine.volumeGb
4100
+ };
4101
+ if (target.volumeGb < machine.volumeGb) throw new CliError(`The volume of ${node.name} holds ${machine.volumeGb} GB, and a volume cannot shrink.`);
4102
+ if (target.size === machine.size && target.volumeGb === machine.volumeGb) throw new CliError(`${node.name} is ${machine.size} with a volume of ${machine.volumeGb} GB already.`, "Name a larger size, a larger volume or both.");
4103
+ return target;
3806
4104
  };
4105
+ const resizedNode = async (api, { organizationId, nodeId }, node, change) => {
4106
+ const body = {
4107
+ revision: node.revision,
4108
+ ...resizeTargetOf(node, change)
4109
+ };
4110
+ validated(resizeNodeBodySchema, body, "a size of a node");
4111
+ return providerDataOf("resize the node", await api.POST("/organizations/{organizationId}/nodes/{nodeId}/resize", {
4112
+ params: { path: {
4113
+ organizationId,
4114
+ nodeId
4115
+ } },
4116
+ body
4117
+ }), machineHints);
4118
+ };
4119
+ const retriedOperation = async (api, { organizationId, nodeId }, revision) => providerDataOf("retry the operation of the node", await api.POST("/organizations/{organizationId}/nodes/{nodeId}/operation/retry", {
4120
+ params: { path: {
4121
+ organizationId,
4122
+ nodeId
4123
+ } },
4124
+ body: { revision }
4125
+ }), machineHints);
3807
4126
  const splitNode = async (api, { organizationId, nodeId }, revision, split) => dataOf(split === null ? "clear the split of the node" : "split the node", await api.PUT("/organizations/{organizationId}/nodes/{nodeId}/split", {
3808
4127
  params: { path: {
3809
4128
  organizationId,
@@ -6337,7 +6656,7 @@ const nodeTools = [
6337
6656
  defineTool({
6338
6657
  name: "nodes_list",
6339
6658
  title: "List nodes",
6340
- description: "List the nodes of the organization this server acts in, the hosts its agents run on, with their state, engine, version, capabilities, split and agent count.",
6659
+ description: "List the nodes of the organization this server acts in, the hosts its agents run on, with their provider, state, engine, version, capabilities, split, agent count, the DigitalOcean machine of a node created there, and the operation the platform runs on it.",
6341
6660
  operations: ["listNodes"],
6342
6661
  safety: "read",
6343
6662
  input: z.object({}),
@@ -6346,7 +6665,7 @@ const nodeTools = [
6346
6665
  defineTool({
6347
6666
  name: "node_show",
6348
6667
  title: "Show a node",
6349
- description: "Show one node with its host, its capabilities, its split, the agents placed on it with their pool limits, and the runtime and pools it reported last.",
6668
+ description: "Show one node with its provider, its DigitalOcean machine and the step and error of its operation, its host, its capabilities, its split, the agents placed on it with their pool limits, and the runtime and pools it reported last.",
6350
6669
  operations: ["getNode"],
6351
6670
  safety: "read",
6352
6671
  input: z.object({ node: nodeField }),
@@ -6389,17 +6708,51 @@ const nodeTools = [
6389
6708
  return splitViewOf(await splitNode(run.api, reference, detail.revision, null));
6390
6709
  }
6391
6710
  }),
6711
+ defineTool({
6712
+ name: "node_resize",
6713
+ title: "Resize a node on DigitalOcean",
6714
+ description: "Give a node created on DigitalOcean a larger droplet size, a larger volume for its agents, or both; a field left out keeps its value, and a node never shrinks. A new size powers the droplet off, so its agents stop until it runs again. Returns the node with the operation that started; node_show follows its steps. Takes an admin of the organization.",
6715
+ operations: ["getNode", "resizeNode"],
6716
+ safety: "destructive",
6717
+ input: z.object({
6718
+ node: nodeField,
6719
+ size: providerSizeSlugSchema.optional().describe("The new droplet size, from provider_catalog."),
6720
+ volumeGb: nodeVolumeGbSchema.optional().describe("The new size of the volume in GB.")
6721
+ }),
6722
+ run: async ({ node, size, volumeGb }, run) => {
6723
+ if (size === void 0 && volumeGb === void 0) throw new CliError("Name a size, a volume or both.");
6724
+ const reference = await resolveNode(run, node);
6725
+ const detail = await nodeDetailOf(run.api, reference);
6726
+ return resizedNode(run.api, reference, detail, {
6727
+ size,
6728
+ volumeGb
6729
+ });
6730
+ }
6731
+ }),
6732
+ defineTool({
6733
+ name: "node_retry",
6734
+ title: "Retry the operation of a node",
6735
+ description: "Run the failed create, resize or delete operation of a node on DigitalOcean again from the step that failed. Returns the node; node_show follows its steps. Takes an admin of the organization.",
6736
+ operations: ["getNode", "retryNodeOperation"],
6737
+ safety: "write",
6738
+ input: z.object({ node: nodeField }),
6739
+ run: async ({ node }, run) => {
6740
+ const reference = await resolveNode(run, node);
6741
+ const detail = await nodeDetailOf(run.api, reference);
6742
+ return retriedOperation(run.api, reference, detail.revision);
6743
+ }
6744
+ }),
6392
6745
  defineTool({
6393
6746
  name: "node_delete",
6394
6747
  title: "Delete a node",
6395
- description: "Delete a node, after which its host can no longer reach the platform. The instance refuses while agents are placed on it.",
6748
+ description: "Delete a node, after which its host can no longer reach the platform. The instance refuses while agents are placed on it. A self-hosted node is deleted at once and returns deleted; for a node on DigitalOcean the platform destroys its droplet and its volume first and returns the node as removing.",
6396
6749
  operations: ["deleteNode"],
6397
6750
  safety: "destructive",
6398
6751
  input: z.object({ node: nodeField }),
6399
6752
  run: async ({ node }, run) => {
6400
6753
  const reference = await resolveNode(run, node);
6401
- await deleteNode(run.api, reference);
6402
- return { deleted: reference.nodeId };
6754
+ const removing = await deleteNode(run.api, reference);
6755
+ return removing === null ? { deleted: reference.nodeId } : { removing };
6403
6756
  }
6404
6757
  })
6405
6758
  ];
@@ -8127,6 +8480,71 @@ const projectTools = [
8127
8480
  })
8128
8481
  ];
8129
8482
  //#endregion
8483
+ //#region src/mcp/tools/providers.ts
8484
+ const accountField = z.string().trim().min(1).describe("The provider account, by its name or its id, from providers_list.");
8485
+ const providerTools = [
8486
+ defineTool({
8487
+ name: "providers_list",
8488
+ title: "List provider accounts",
8489
+ description: "List the DigitalOcean accounts of the organization this server acts in, with their default region, the number of nodes created with each, and the last check of their token, never the token itself.",
8490
+ operations: ["listProviderAccounts"],
8491
+ safety: "read",
8492
+ input: z.object({}),
8493
+ run: async (_, run) => providerAccountsOf(run.api, (await run.organization()).id)
8494
+ }),
8495
+ defineTool({
8496
+ name: "provider_show",
8497
+ title: "Show a provider account",
8498
+ description: "Show one provider account with its default region, its node count and the last check of its token: when it ran, the scopes it lacked and why it failed.",
8499
+ operations: ["getProviderAccount"],
8500
+ safety: "read",
8501
+ input: z.object({ account: accountField }),
8502
+ run: async ({ account }, run) => resolveProviderAccount(run, account)
8503
+ }),
8504
+ defineTool({
8505
+ name: "provider_catalog",
8506
+ title: "List regions and sizes",
8507
+ description: "List the regions of a provider account that offer volumes, and the sizes with at least 2 GB of memory a node can have in one region, with CPUs, memory in MB, disk in GB and the monthly price in USD. The region defaults to the default region of the account.",
8508
+ operations: ["getProviderCatalog"],
8509
+ safety: "read",
8510
+ input: z.object({
8511
+ account: accountField,
8512
+ region: providerRegionSlugSchema.optional().describe("The region whose sizes to list, as in fra1.")
8513
+ }),
8514
+ run: async ({ account, region }, run) => {
8515
+ const found = await resolveProviderAccount(run, account);
8516
+ const catalog = await providerCatalogOf(run.api, (await run.organization()).id, found.id);
8517
+ return catalogInRegion(catalog, region ?? found.defaultRegion);
8518
+ }
8519
+ }),
8520
+ defineTool({
8521
+ name: "provider_verify",
8522
+ title: "Check the token of a provider account",
8523
+ description: "Check the token of a provider account against DigitalOcean again and return the account with the result: missingScopes lists the scopes it lacks, and failure is unauthorized when DigitalOcean refused it. Takes an admin of the organization.",
8524
+ operations: ["verifyProviderAccount"],
8525
+ safety: "write",
8526
+ idempotent: true,
8527
+ input: z.object({ account: accountField }),
8528
+ run: async ({ account }, run) => {
8529
+ const found = await resolveProviderAccount(run, account);
8530
+ return verifiedAccount(run.api, (await run.organization()).id, found.id);
8531
+ }
8532
+ }),
8533
+ defineTool({
8534
+ name: "provider_delete",
8535
+ title: "Delete a provider account",
8536
+ description: "Delete a provider account and erase its token. The instance refuses while nodes created with it exist. Takes an admin of the organization.",
8537
+ operations: ["deleteProviderAccount"],
8538
+ safety: "destructive",
8539
+ input: z.object({ account: accountField }),
8540
+ run: async ({ account }, run) => {
8541
+ const found = await resolveProviderAccount(run, account);
8542
+ await deleteProviderAccount(run.api, (await run.organization()).id, found.id);
8543
+ return { deleted: found.id };
8544
+ }
8545
+ })
8546
+ ];
8547
+ //#endregion
8130
8548
  //#region src/mcp/tools/releases.ts
8131
8549
  const releaseTools = [defineTool({
8132
8550
  name: "releases_list",
@@ -8534,6 +8952,7 @@ const kcloudTools = [
8534
8952
  ...credentialTools,
8535
8953
  ...repositoryTools,
8536
8954
  ...projectToolTools,
8955
+ ...providerTools,
8537
8956
  ...nodeTools,
8538
8957
  ...agentTools,
8539
8958
  ...agentBackupTools,
@@ -8551,7 +8970,7 @@ const instructions = [
8551
8970
  "These tools act on one Kikimora Cloud instance, as the account kcloud is signed in with, in one organization of it. whoami names all three.",
8552
8971
  "Name a project by its slug, its name or its id. Name an agent as <project>/<agent>, as its slug or name together with project, or by its id.",
8553
8972
  "A refusal quotes the error code of the instance. Its hint may name a kcloud command, such as kcloud agents list; the tool for the same resource and verb, agents_list, does that work here.",
8554
- "Secrets never pass through these tools. For them the person at the terminal runs kcloud credentials add, kcloud credentials rotate, kcloud tokens create, kcloud auth password, kcloud orgs members add, kcloud orgs members reset-password, or sets the secrets of a tool in the panel."
8973
+ "Secrets never pass through these tools. For them the person at the terminal runs kcloud credentials add, kcloud credentials rotate, kcloud providers add, kcloud providers update, kcloud tokens create, kcloud auth password, kcloud orgs members add, kcloud orgs members reset-password, or sets the secrets of a tool in the panel."
8555
8974
  ].join("\n");
8556
8975
  const createKcloudServer = ({ version, open }) => {
8557
8976
  const server = new McpServer({
@@ -8615,6 +9034,7 @@ const createMcpCommand = (adaptersOf, cliVersion) => defineCommand({
8615
9034
  });
8616
9035
  //#endregion
8617
9036
  //#region src/commands/nodes/index.ts
9037
+ const defaultVolumeGb = 40;
8618
9038
  const gibibytes = (bytes) => `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
8619
9039
  const hostOf = (node) => node.host === null ? "-" : `${node.host.cpus} CPU, ${gibibytes(node.host.memoryBytes)} of memory`;
8620
9040
  const capabilitiesOf = (node) => {
@@ -8624,6 +9044,25 @@ const capabilitiesOf = (node) => {
8624
9044
  };
8625
9045
  const registrationOf = (node) => node.registrationExpiresAt === null ? "-" : `a token waits until ${node.registrationExpiresAt}`;
8626
9046
  const storageOf = ({ state, freeBytes, totalBytes, path }) => `${state}, ${gibibytes(freeBytes)} free of ${gibibytes(totalBytes)} at ${path}`;
9047
+ const shapeText = ({ size, volumeGb }) => `${size} with a volume of ${volumeGb} GB`;
9048
+ const machineText = (machine) => `${machine.size} in ${machine.region} with a volume of ${machine.volumeGb} GB`;
9049
+ const dropletText = ({ dropletId, publicIpv4 }) => dropletId === null ? "not created yet" : `${dropletId} at ${publicIpv4 ?? "no address yet"}`;
9050
+ const operationText = ({ kind, state, step }) => `${kind} ${state}${step === null ? "" : ` at ${step}`}`;
9051
+ const machineRowsOf = (machine) => machine === null ? [] : [
9052
+ ["machine", machineText(machine)],
9053
+ ["account", machine.account.name ?? machine.account.id],
9054
+ ["droplet", dropletText(machine)]
9055
+ ];
9056
+ const operationRowsOf = (operation) => {
9057
+ if (operation === null) return [];
9058
+ const { target, error } = operation;
9059
+ return [
9060
+ ["operation", operationText(operation)],
9061
+ ...target === null ? [] : [["target", shapeText(target)]],
9062
+ ["started", operation.startedAt],
9063
+ ...error === null ? [] : [["error", error]]
9064
+ ];
9065
+ };
8627
9066
  const runtimeRowsOf = (runtime) => [
8628
9067
  ["reported", runtime.reportedAt],
8629
9068
  ["docker", runtime.docker.lastError === null ? runtime.docker.state : `${runtime.docker.state}: ${runtime.docker.lastError}`],
@@ -8654,6 +9093,8 @@ const nodeDetailsText = (node) => [
8654
9093
  ["name", node.name],
8655
9094
  ["state", node.state],
8656
9095
  ["provider", node.provider],
9096
+ ...machineRowsOf(node.machine),
9097
+ ...operationRowsOf(node.operation),
8657
9098
  ["engine", node.engine ?? "-"],
8658
9099
  ["version", node.version ?? "-"],
8659
9100
  ["host", hostOf(node)],
@@ -8692,11 +9133,13 @@ const nodeDetailsText = (node) => [
8692
9133
  poolsText(node.runtime.pools)
8693
9134
  ]
8694
9135
  ].join("\n");
8695
- const startHintOf = (origin) => [
8696
- "Start the node with it as docs/install.md of kikimora-cloud describes. On a Linux host, after read -rs token:",
8697
- ` printf '%s\\n' "$token" | /opt/kikimora-cloud/infra/node/install.sh --platform-url ${origin} --image ghcr.io/kikimoradev/kikimora-cloud/node:<version> --registration-token -`,
8698
- "On a Windows PC:",
8699
- ` C:\\kikimora-cloud\\src\\infra\\windows\\install.ps1 -PlatformUrl ${origin} -RegistrationToken <token>`
9136
+ const installCommandsOf = (origin) => [
9137
+ "Install the node with it, putting the token in place of <token>. On a Linux host:",
9138
+ ` curl -fsSLo install-node.sh ${origin}/install-node.sh`,
9139
+ ` printf '%s\\n' '<token>' | sudo sh install-node.sh --platform-url ${origin} --registration-token -`,
9140
+ "On Windows, in PowerShell as administrator:",
9141
+ ` Invoke-WebRequest -UseBasicParsing -OutFile install-node.ps1 ${origin}/install-node.ps1`,
9142
+ ` powershell -NoProfile -ExecutionPolicy Bypass -File .\\install-node.ps1 -PlatformUrl ${origin} -RegistrationToken <token>`
8700
9143
  ].join("\n");
8701
9144
  const printedRegistration = (run, created) => {
8702
9145
  if (run.settings.json) {
@@ -8704,9 +9147,56 @@ const printedRegistration = (run, created) => {
8704
9147
  return exitCodes.ok;
8705
9148
  }
8706
9149
  run.context.terminal.line(created.registration.token);
8707
- run.context.terminal.error([`The registration token of ${created.node.name} is shown only here and works once, until ${created.registration.expiresAt}.`, startHintOf(run.origin)].join("\n"));
9150
+ run.context.terminal.error([`The registration token of ${created.node.name} is shown only here and works once, until ${created.registration.expiresAt}.`, installCommandsOf(run.origin)].join("\n"));
8708
9151
  return exitCodes.ok;
8709
9152
  };
9153
+ const printedCreation = (run, created) => {
9154
+ const { node, registration } = created;
9155
+ if (registration !== null) return printedRegistration(run, {
9156
+ node,
9157
+ registration
9158
+ });
9159
+ return printed(run, created, () => [`Creating ${node.name} on DigitalOcean${node.machine === null ? "" : `, ${machineText(node.machine)}`}.`, `It joins the platform on its own; kcloud nodes show ${node.name} follows the steps.`].join("\n"));
9160
+ };
9161
+ const createOptions = {
9162
+ provider: { type: "string" },
9163
+ account: { type: "string" },
9164
+ region: { type: "string" },
9165
+ size: { type: "string" },
9166
+ "volume-gb": { type: "string" }
9167
+ };
9168
+ const volumeGbOf = (given) => {
9169
+ if (given === void 0) return void 0;
9170
+ if (!/^[0-9]+$/u.test(given)) throw new UsageError(`--volume-gb ${given} is not a whole number of gigabytes.`);
9171
+ return Number(given);
9172
+ };
9173
+ const machineFlags = [
9174
+ "account",
9175
+ "region",
9176
+ "size",
9177
+ "volume-gb"
9178
+ ];
9179
+ const nodeBodyOf = async (run, name, values) => {
9180
+ const provider = nodeProviders.find((candidate) => candidate === (values.provider ?? "self_hosted"));
9181
+ if (provider === void 0) throw new UsageError(`--provider ${values.provider ?? ""} is not a provider of a node.`, `The providers are ${nodeProviders.join(", ")}.`);
9182
+ if (provider === "self_hosted") {
9183
+ const given = machineFlags.filter((flag) => values[flag] !== void 0);
9184
+ if (given.length > 0) throw new UsageError(`${given.map((flag) => `--${flag}`).join(", ")} need --provider digitalocean.`);
9185
+ return { name };
9186
+ }
9187
+ if (values.account === void 0 || values.size === void 0) throw new UsageError("A node on DigitalOcean needs --account <account> and --size <slug>.", "kcloud providers catalog <account> lists the sizes.");
9188
+ const volumeGb = volumeGbOf(values["volume-gb"]) ?? defaultVolumeGb;
9189
+ const account = await resolveProviderAccount(run, values.account);
9190
+ return {
9191
+ name,
9192
+ machine: {
9193
+ accountId: account.id,
9194
+ region: values.region ?? account.defaultRegion,
9195
+ size: values.size,
9196
+ volumeGb
9197
+ }
9198
+ };
9199
+ };
8710
9200
  const createNodesCommand = (adaptersOf) => defineGroup({
8711
9201
  name: "nodes",
8712
9202
  summary: "The nodes of the organization, the hosts its agents run on.",
@@ -8727,7 +9217,9 @@ const createNodesCommand = (adaptersOf) => defineGroup({
8727
9217
  const items = await nodesOf(run.api, organization.id);
8728
9218
  return printed(run, items, (nodes) => nodes.length === 0 ? `${organization.slug} has no node yet; kcloud nodes create <name> creates one.` : renderTable([
8729
9219
  { header: "NAME" },
9220
+ { header: "PROVIDER" },
8730
9221
  { header: "STATE" },
9222
+ { header: "OPERATION" },
8731
9223
  { header: "ENGINE" },
8732
9224
  { header: "VERSION" },
8733
9225
  {
@@ -8738,7 +9230,9 @@ const createNodesCommand = (adaptersOf) => defineGroup({
8738
9230
  { header: "ID" }
8739
9231
  ], nodes.map((node) => [
8740
9232
  node.name,
9233
+ node.provider,
8741
9234
  node.state,
9235
+ node.operation === null ? "-" : operationText(node.operation),
8742
9236
  node.engine ?? "-",
8743
9237
  node.version ?? "-",
8744
9238
  String(node.agentCount),
@@ -8749,7 +9243,7 @@ const createNodesCommand = (adaptersOf) => defineGroup({
8749
9243
  }),
8750
9244
  defineCommand({
8751
9245
  name: "show",
8752
- summary: "Show a node with its host, its split, the agents placed on it, its runtime and its pools.",
9246
+ summary: "Show a node with its machine, its host, its split, its agents, its runtime and its pools.",
8753
9247
  usage: "nodes show <node>",
8754
9248
  optionsUsage: nodeUsage,
8755
9249
  options: {},
@@ -8766,10 +9260,18 @@ const createNodesCommand = (adaptersOf) => defineGroup({
8766
9260
  createSplitCommand(adaptersOf),
8767
9261
  defineCommand({
8768
9262
  name: "create",
8769
- summary: "Create a node and print its one-time registration token.",
8770
- usage: "nodes create <name>",
8771
- optionsUsage: [["", "the name holds lowercase letters, digits and hyphens"], ["", "the token goes to stdout alone, how to start the node to stderr"]],
8772
- options: {},
9263
+ summary: "Create a node on a host of your own, printing its one-time registration token, or on DigitalOcean.",
9264
+ usage: "nodes create <name> [--provider digitalocean --account <account> --size <slug>]",
9265
+ optionsUsage: [
9266
+ ["", "the name holds lowercase letters, digits and hyphens"],
9267
+ ["--provider <provider>", `one of ${nodeProviders.join(", ")} (default: self_hosted)`],
9268
+ ["", "self_hosted: the token goes to stdout alone, how to install to stderr"],
9269
+ ["--account <account>", "the DigitalOcean account, by name or id"],
9270
+ ["--size <slug>", "the droplet size, as kcloud providers catalog lists them"],
9271
+ ["--region <slug>", "the region (default: the region of the account)"],
9272
+ ["--volume-gb <n>", `the volume for the agents (default: ${defaultVolumeGb})`]
9273
+ ],
9274
+ options: createOptions,
8773
9275
  positionals: {
8774
9276
  min: 1,
8775
9277
  max: 1
@@ -8777,7 +9279,65 @@ const createNodesCommand = (adaptersOf) => defineGroup({
8777
9279
  run: async (invocation) => {
8778
9280
  const run = await runOf(adaptersOf, invocation);
8779
9281
  const organization = await run.organization();
8780
- return printedRegistration(run, await createdNode(run.api, organization.id, invocation.positionals[0] ?? ""));
9282
+ const body = await nodeBodyOf(run, invocation.positionals[0] ?? "", invocation.values);
9283
+ return printedCreation(run, await createdNode(run.api, organization.id, body));
9284
+ }
9285
+ }),
9286
+ defineCommand({
9287
+ name: "resize",
9288
+ summary: "Give a node on DigitalOcean a larger size, a larger volume or both.",
9289
+ usage: "nodes resize <node> [--size <slug>] [--volume-gb <n>]",
9290
+ optionsUsage: [
9291
+ ...nodeUsage,
9292
+ ["--size <slug>", "the new droplet size, with more CPUs or memory"],
9293
+ ["--volume-gb <n>", "the new size of the volume for the agents"],
9294
+ ["", "an option left out keeps its value; a node never shrinks"]
9295
+ ],
9296
+ options: {
9297
+ size: { type: "string" },
9298
+ "volume-gb": { type: "string" }
9299
+ },
9300
+ positionals: {
9301
+ min: 1,
9302
+ max: 1
9303
+ },
9304
+ run: async (invocation) => {
9305
+ const { values } = invocation;
9306
+ const change = {
9307
+ size: values.size,
9308
+ volumeGb: volumeGbOf(values["volume-gb"])
9309
+ };
9310
+ if (change.size === void 0 && change.volumeGb === void 0) throw new UsageError("nodes resize needs --size <slug>, --volume-gb <n> or both.");
9311
+ const run = await runOf(adaptersOf, invocation);
9312
+ const given = invocation.positionals[0] ?? "";
9313
+ const reference = await resolveNode(run, given);
9314
+ const node = await nodeDetailOf(run.api, reference);
9315
+ const target = resizeTargetOf(node, change);
9316
+ await confirmed({
9317
+ prompt: run.prompt,
9318
+ settings: run.settings,
9319
+ question: `Resize ${given} to ${shapeText(target)}? ${target.size === node.machine?.size ? "The volume cannot shrink afterwards." : "The droplet is off during the resize, so its agents stop until it runs again, and it cannot shrink afterwards."}`
9320
+ });
9321
+ const resized = await resizedNode(run.api, reference, node, change);
9322
+ return printed(run, resized, (detail) => `Resizing ${detail.name} to ${shapeText(target)}; kcloud nodes show ${detail.name} follows the steps.`);
9323
+ }
9324
+ }),
9325
+ defineCommand({
9326
+ name: "retry",
9327
+ summary: "Retry the failed operation of a node on DigitalOcean from the step that failed.",
9328
+ usage: "nodes retry <node>",
9329
+ optionsUsage: nodeUsage,
9330
+ options: {},
9331
+ positionals: {
9332
+ min: 1,
9333
+ max: 1
9334
+ },
9335
+ run: async (invocation) => {
9336
+ const run = await runOf(adaptersOf, invocation);
9337
+ const reference = await resolveNode(run, invocation.positionals[0] ?? "");
9338
+ const node = await nodeDetailOf(run.api, reference);
9339
+ const retried = await retriedOperation(run.api, reference, node.revision);
9340
+ return printed(run, retried, (detail) => detail.operation === null ? `${detail.name} has no operation left to run.` : `Retrying ${operationText(detail.operation)} of ${detail.name}; kcloud nodes show ${detail.name} follows the steps.`);
8781
9341
  }
8782
9342
  }),
8783
9343
  defineCommand({
@@ -8798,7 +9358,7 @@ const createNodesCommand = (adaptersOf) => defineGroup({
8798
9358
  }),
8799
9359
  defineCommand({
8800
9360
  name: "delete",
8801
- summary: "Delete a node that runs no agent.",
9361
+ summary: "Delete a node that runs no agent, with its droplet on DigitalOcean.",
8802
9362
  usage: "nodes delete <node>",
8803
9363
  optionsUsage: nodeUsage,
8804
9364
  options: {},
@@ -8810,13 +9370,300 @@ const createNodesCommand = (adaptersOf) => defineGroup({
8810
9370
  const run = await runOf(adaptersOf, invocation);
8811
9371
  const given = invocation.positionals[0] ?? "";
8812
9372
  const reference = await resolveNode(run, given);
9373
+ const node = await nodeDetailOf(run.api, reference);
9374
+ await confirmed({
9375
+ prompt: run.prompt,
9376
+ settings: run.settings,
9377
+ question: node.machine === null ? `Delete the node ${given}? Its host can no longer reach the platform.` : `Delete the node ${given}? The platform destroys its droplet and its volume with the data of its agents.`
9378
+ });
9379
+ const removing = await deleteNode(run.api, reference);
9380
+ if (removing === null) return said(run, `Deleted the node ${given}.`);
9381
+ return printed(run, removing, (removed) => `The removal of ${removed.name} started: the platform destroys its droplet, its volume and its firewall, then forgets the node. kcloud nodes list shows it until then.`);
9382
+ }
9383
+ })
9384
+ ]
9385
+ });
9386
+ //#endregion
9387
+ //#region src/commands/providers/index.ts
9388
+ const accountUsage = [["", "an account is named by its name or its id"]];
9389
+ const regionOption = { region: { type: "string" } };
9390
+ const tokenOf = async (run, name, values) => {
9391
+ const token = await secretOf({
9392
+ run,
9393
+ file: values["secret-file"],
9394
+ fromStdin: values["secret-stdin"],
9395
+ question: `The DigitalOcean token of ${name}`,
9396
+ stdinHint: "Pipe the token in, or drop --secret-stdin to be asked for it."
9397
+ });
9398
+ validated(providerTokenSchema, token, "a DigitalOcean token");
9399
+ return token;
9400
+ };
9401
+ const gigabytes = (megabytes) => `${megabytes / 1024} GB`;
9402
+ const accountDetailsText = (account) => [renderTable([{ header: "FIELD" }, { header: "VALUE" }], [
9403
+ ["name", account.name],
9404
+ ["kind", account.kind],
9405
+ ["default region", account.defaultRegion],
9406
+ ["nodes", String(account.nodeCount)],
9407
+ ["token", verificationText(account.verification)],
9408
+ ["checked", account.verification.checkedAt],
9409
+ ["created", account.createdAt],
9410
+ ["updated", account.updatedAt],
9411
+ ["id", account.id]
9412
+ ]), ...account.verification.missingScopes.length === 0 ? [] : ["", `The token lacks ${account.verification.missingScopes.join(", ")}.`]].join("\n");
9413
+ const verificationSentence = (account) => {
9414
+ const { failure, missingScopes } = account.verification;
9415
+ if (failure === "unauthorized") return `DigitalOcean refused the token of ${account.name}; kcloud providers update ${account.name} --token replaces it.`;
9416
+ if (failure !== null) return `The token of ${account.name} could not be checked: ${failure}.`;
9417
+ return missingScopes.length === 0 ? `The token of ${account.name} has every scope a node needs.` : `The token of ${account.name} lacks ${missingScopes.join(", ")}.`;
9418
+ };
9419
+ const catalogText = (catalog) => [
9420
+ renderTable([
9421
+ { header: "REGION" },
9422
+ { header: "NAME" },
9423
+ { header: "AVAILABLE" }
9424
+ ], catalog.regions.map((region) => [
9425
+ region.slug === catalog.region ? `${region.slug} *` : region.slug,
9426
+ region.name,
9427
+ region.available ? "yes" : "no"
9428
+ ])),
9429
+ "",
9430
+ catalog.sizes.length === 0 ? `No size for a node is offered in ${catalog.region}.` : renderTable([
9431
+ { header: `SIZE IN ${catalog.region.toUpperCase()}` },
9432
+ {
9433
+ header: "CPU",
9434
+ align: "right"
9435
+ },
9436
+ {
9437
+ header: "MEMORY",
9438
+ align: "right"
9439
+ },
9440
+ {
9441
+ header: "DISK",
9442
+ align: "right"
9443
+ },
9444
+ {
9445
+ header: "USD A MONTH",
9446
+ align: "right"
9447
+ }
9448
+ ], catalog.sizes.map((size) => [
9449
+ size.slug,
9450
+ String(size.vcpus),
9451
+ gigabytes(size.memoryMb),
9452
+ `${size.diskGb} GB`,
9453
+ size.priceMonthly.toFixed(2)
9454
+ ]))
9455
+ ].join("\n");
9456
+ const createProvidersCommand = (adaptersOf) => defineGroup({
9457
+ name: "providers",
9458
+ summary: "The cloud accounts the platform creates the nodes of the organization in.",
9459
+ usage: "providers <command>",
9460
+ subcommands: [
9461
+ defineCommand({
9462
+ name: "list",
9463
+ summary: "List the provider accounts of the organization.",
9464
+ usage: "providers list",
9465
+ options: {},
9466
+ positionals: {
9467
+ min: 0,
9468
+ max: 0
9469
+ },
9470
+ run: async (invocation) => {
9471
+ const run = await runOf(adaptersOf, invocation);
9472
+ const organization = await run.organization();
9473
+ const items = await providerAccountsOf(run.api, organization.id);
9474
+ return printed(run, items, (accounts) => accounts.length === 0 ? `${organization.slug} has no provider account yet; kcloud providers add <name> --region <slug> adds one.` : renderTable([
9475
+ { header: "NAME" },
9476
+ { header: "KIND" },
9477
+ { header: "REGION" },
9478
+ {
9479
+ header: "NODES",
9480
+ align: "right"
9481
+ },
9482
+ { header: "TOKEN" },
9483
+ { header: "ID" }
9484
+ ], accounts.map((account) => [
9485
+ account.name,
9486
+ account.kind,
9487
+ account.defaultRegion,
9488
+ String(account.nodeCount),
9489
+ verificationText(account.verification),
9490
+ account.id
9491
+ ])));
9492
+ }
9493
+ }),
9494
+ defineCommand({
9495
+ name: "show",
9496
+ summary: "Show a provider account with the state of its token.",
9497
+ usage: "providers show <account>",
9498
+ optionsUsage: accountUsage,
9499
+ options: {},
9500
+ positionals: {
9501
+ min: 1,
9502
+ max: 1
9503
+ },
9504
+ run: async (invocation) => {
9505
+ const run = await runOf(adaptersOf, invocation);
9506
+ const account = await resolveProviderAccount(run, invocation.positionals[0] ?? "");
9507
+ return printed(run, account, accountDetailsText);
9508
+ }
9509
+ }),
9510
+ defineCommand({
9511
+ name: "add",
9512
+ summary: "Add a DigitalOcean account, asking for its token.",
9513
+ usage: "providers add <name> --region <slug> [--secret-file <path>]",
9514
+ optionsUsage: [
9515
+ ["--region <slug>", "the region a new node goes to by default, as in fra1"],
9516
+ ...secretUsage,
9517
+ ["", "the instance checks the token before it stores it; the token needs"],
9518
+ ...scopeLines().map((line) => ["", ` ${line}`])
9519
+ ],
9520
+ options: {
9521
+ ...regionOption,
9522
+ ...secretOptions
9523
+ },
9524
+ positionals: {
9525
+ min: 1,
9526
+ max: 1
9527
+ },
9528
+ run: async (invocation) => {
9529
+ const { values } = invocation;
9530
+ const run = await runOf(adaptersOf, invocation);
9531
+ const organization = await run.organization();
9532
+ const name = invocation.positionals[0] ?? "";
9533
+ if (values.region === void 0) throw new UsageError("providers add needs --region <slug>.", "Name the region a new node goes to by default, as in fra1.");
9534
+ const draft = {
9535
+ kind: "digitalocean",
9536
+ name,
9537
+ defaultRegion: values.region
9538
+ };
9539
+ validated(createProviderAccountBodySchema.omit({ token: true }), draft, "a provider account");
9540
+ const token = await tokenOf(run, name, values);
9541
+ const account = providerDataOf("add the provider account", await run.api.POST("/organizations/{organizationId}/provider-accounts", {
9542
+ params: { path: { organizationId: organization.id } },
9543
+ body: {
9544
+ ...draft,
9545
+ token
9546
+ }
9547
+ }));
9548
+ return printed(run, account, (added) => `Added ${added.name} as ${added.id}; its token has every scope a node needs.`);
9549
+ }
9550
+ }),
9551
+ defineCommand({
9552
+ name: "update",
9553
+ summary: "Rename a provider account, change its default region or replace its token.",
9554
+ usage: "providers update <account> [--name <name>] [--region <slug>] [--token]",
9555
+ optionsUsage: [
9556
+ ...accountUsage,
9557
+ ["--name <name>", "the new name"],
9558
+ ["--region <slug>", "the new default region of new nodes"],
9559
+ ["--token", "replace the token, asking for the new one"],
9560
+ ...secretUsage,
9561
+ ["", `the new token needs the ${providerTokenScopes.length} scopes add lists`]
9562
+ ],
9563
+ options: {
9564
+ name: { type: "string" },
9565
+ ...regionOption,
9566
+ token: {
9567
+ type: "boolean",
9568
+ default: false
9569
+ },
9570
+ ...secretOptions
9571
+ },
9572
+ positionals: {
9573
+ min: 1,
9574
+ max: 1
9575
+ },
9576
+ run: async (invocation) => {
9577
+ const { values } = invocation;
9578
+ const replacesToken = values.token || values["secret-stdin"] || values["secret-file"] !== void 0;
9579
+ if (values.name === void 0 && values.region === void 0 && !replacesToken) throw new UsageError("providers update needs --name, --region or --token.");
9580
+ const run = await runOf(adaptersOf, invocation);
9581
+ const organization = await run.organization();
9582
+ const account = await resolveProviderAccount(run, invocation.positionals[0] ?? "");
9583
+ const change = {
9584
+ revision: account.revision,
9585
+ ...values.name === void 0 ? {} : { name: values.name },
9586
+ ...values.region === void 0 ? {} : { defaultRegion: values.region }
9587
+ };
9588
+ validated(createProviderAccountBodySchema.pick({
9589
+ name: true,
9590
+ defaultRegion: true
9591
+ }).partial(), change, "a change of a provider account");
9592
+ const body = replacesToken ? {
9593
+ ...change,
9594
+ token: await tokenOf(run, account.name, values)
9595
+ } : change;
9596
+ const updated = providerDataOf("update the provider account", await run.api.PATCH("/organizations/{organizationId}/provider-accounts/{accountId}", {
9597
+ params: { path: {
9598
+ organizationId: organization.id,
9599
+ accountId: account.id
9600
+ } },
9601
+ body
9602
+ }));
9603
+ return printed(run, updated, (changed) => `Updated ${changed.name}${replacesToken ? "; its new token has every scope a node needs" : ""}.`);
9604
+ }
9605
+ }),
9606
+ defineCommand({
9607
+ name: "verify",
9608
+ summary: "Check the token of a provider account against DigitalOcean again.",
9609
+ usage: "providers verify <account>",
9610
+ optionsUsage: accountUsage,
9611
+ options: {},
9612
+ positionals: {
9613
+ min: 1,
9614
+ max: 1
9615
+ },
9616
+ run: async (invocation) => {
9617
+ const run = await runOf(adaptersOf, invocation);
9618
+ const organization = await run.organization();
9619
+ const account = await resolveProviderAccount(run, invocation.positionals[0] ?? "");
9620
+ return printed(run, await verifiedAccount(run.api, organization.id, account.id), verificationSentence);
9621
+ }
9622
+ }),
9623
+ defineCommand({
9624
+ name: "catalog",
9625
+ summary: "List the regions of a provider account and the sizes a node can have in one.",
9626
+ usage: "providers catalog <account> [--region <slug>]",
9627
+ optionsUsage: [
9628
+ ...accountUsage,
9629
+ ["--region <slug>", "the region whose sizes to list (default: the account's)"],
9630
+ ["", "only regions with volumes and sizes with at least 2 GB of memory"]
9631
+ ],
9632
+ options: regionOption,
9633
+ positionals: {
9634
+ min: 1,
9635
+ max: 1
9636
+ },
9637
+ run: async (invocation) => {
9638
+ const run = await runOf(adaptersOf, invocation);
9639
+ const organization = await run.organization();
9640
+ const account = await resolveProviderAccount(run, invocation.positionals[0] ?? "");
9641
+ const catalog = await providerCatalogOf(run.api, organization.id, account.id);
9642
+ return printed(run, catalogInRegion(catalog, invocation.values.region ?? account.defaultRegion), catalogText);
9643
+ }
9644
+ }),
9645
+ defineCommand({
9646
+ name: "delete",
9647
+ summary: "Delete a provider account no node was created with.",
9648
+ usage: "providers delete <account>",
9649
+ optionsUsage: accountUsage,
9650
+ options: {},
9651
+ positionals: {
9652
+ min: 1,
9653
+ max: 1
9654
+ },
9655
+ run: async (invocation) => {
9656
+ const run = await runOf(adaptersOf, invocation);
9657
+ const organization = await run.organization();
9658
+ const given = invocation.positionals[0] ?? "";
9659
+ const account = await resolveProviderAccount(run, given);
8813
9660
  await confirmed({
8814
9661
  prompt: run.prompt,
8815
9662
  settings: run.settings,
8816
- question: `Delete the node ${given}? Its host can no longer reach the platform.`
9663
+ question: `Delete the provider account ${given}? The platform erases its token.`
8817
9664
  });
8818
- await deleteNode(run.api, reference);
8819
- return said(run, `Deleted the node ${given}.`);
9665
+ await deleteProviderAccount(run.api, organization.id, account.id);
9666
+ return said(run, `Deleted the provider account ${given}.`);
8820
9667
  }
8821
9668
  })
8822
9669
  ]
@@ -8868,6 +9715,7 @@ const kcloudCommands = () => [
8868
9715
  createCredentialsCommand(createAdapters),
8869
9716
  createRepositoriesCommand(createAdapters),
8870
9717
  createToolsCommand(createAdapters),
9718
+ createProvidersCommand(createAdapters),
8871
9719
  createNodesCommand(createAdapters),
8872
9720
  createAgentsCommand(createAdapters),
8873
9721
  createNotificationsCommand(createAdapters),
package/package.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "publishConfig": {
16
16
  "access": "public"
17
17
  },
18
- "version": "0.17.0",
18
+ "version": "0.18.0",
19
19
  "dependencies": {
20
20
  "@clack/prompts": "1.8.1",
21
21
  "@modelcontextprotocol/server": "2.1.0",