@khotan/cli 0.5.0 → 0.8.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.
- package/README.md +22 -0
- package/dist/khotan.js +945 -53
- package/package.json +4 -4
package/dist/khotan.js
CHANGED
|
@@ -19,7 +19,7 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
|
19
19
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
20
20
|
|
|
21
21
|
// ../khotan-core/src/version.ts
|
|
22
|
-
var CATALOG_SCHEMA_VERSION = 1, CATALOG_VERSION = "2026-06-
|
|
22
|
+
var CATALOG_SCHEMA_VERSION = 1, CATALOG_VERSION = "2026-06-28", KHOTAN_ADAPTER_NAME = "khotan", KHOTAN_ADAPTER_VERSION = "0.7.0", SUPPORTED_CATALOG_SCHEMA_VERSIONS;
|
|
23
23
|
var init_version = __esm(() => {
|
|
24
24
|
SUPPORTED_CATALOG_SCHEMA_VERSIONS = [
|
|
25
25
|
CATALOG_SCHEMA_VERSION
|
|
@@ -115,6 +115,12 @@ function platformDomain(config) {
|
|
|
115
115
|
const idArg = idField(resourceParam, `The ${noun} id.`);
|
|
116
116
|
const cap = domain;
|
|
117
117
|
const a = article(noun);
|
|
118
|
+
const envWriteTargetField = {
|
|
119
|
+
name: "target",
|
|
120
|
+
location: "body",
|
|
121
|
+
type: "string[]",
|
|
122
|
+
description: "Environments to write, any of: production, preview, development (comma-separated or repeatable). Omit to write all environments."
|
|
123
|
+
};
|
|
118
124
|
const tool = (suffix) => `khotan_${domain}_${suffix}`;
|
|
119
125
|
const operations = [
|
|
120
126
|
{
|
|
@@ -220,14 +226,15 @@ function platformDomain(config) {
|
|
|
220
226
|
safety: "write",
|
|
221
227
|
auth: true,
|
|
222
228
|
title: `Bulk upsert ${noun} environment variables`,
|
|
223
|
-
description: `Create or update many env vars in one request.`,
|
|
229
|
+
description: `Create or update many env vars in one request. Writes every environment unless you pass target.`,
|
|
224
230
|
input: [
|
|
225
231
|
idArg,
|
|
226
|
-
{ name: "variables", location: "body", type: "json", required: true, description: "Array of { key, value } to upsert." }
|
|
232
|
+
{ name: "variables", location: "body", type: "json", required: true, description: "Array of { key, value } to upsert." },
|
|
233
|
+
envWriteTargetField
|
|
227
234
|
],
|
|
228
235
|
http: { method: "POST", path: `${base}/{${resourceParam}}/env`, operationId: operationIds.envBulkSet, bodyMode: "json" },
|
|
229
236
|
cli: { command: [domain, "env", "bulk-set"], summary: "Bulk upsert env vars" },
|
|
230
|
-
mcp: { tool: { name: tool("env_bulk_set"), title: "Bulk upsert env vars", description: "Create or update many env vars in one request." } }
|
|
237
|
+
mcp: { tool: { name: tool("env_bulk_set"), title: "Bulk upsert env vars", description: "Create or update many env vars in one request. Writes every environment unless target is provided." } }
|
|
231
238
|
},
|
|
232
239
|
{
|
|
233
240
|
id: `${cap}.env.reveal`,
|
|
@@ -236,8 +243,17 @@ function platformDomain(config) {
|
|
|
236
243
|
safety: "secret",
|
|
237
244
|
auth: true,
|
|
238
245
|
title: `Reveal one ${noun} environment variable value`,
|
|
239
|
-
description: `Reveal a single decrypted env var value. Secret-bearing; explicit-only.`,
|
|
240
|
-
input: [
|
|
246
|
+
description: `Reveal a single decrypted env var value for one environment. Secret-bearing; explicit-only.`,
|
|
247
|
+
input: [
|
|
248
|
+
idArg,
|
|
249
|
+
idField("key", "The env var key to reveal."),
|
|
250
|
+
{
|
|
251
|
+
name: "target",
|
|
252
|
+
location: "query",
|
|
253
|
+
type: "string",
|
|
254
|
+
description: "Environment to read: production, preview, or development. Omit to default to production."
|
|
255
|
+
}
|
|
256
|
+
],
|
|
241
257
|
http: { method: "GET", path: `${base}/{${resourceParam}}/env/{key}`, operationId: operationIds.envReveal },
|
|
242
258
|
cli: { command: [domain, "env", "reveal"], summary: "Reveal one env var value (secret)", defaultOutput: "raw" },
|
|
243
259
|
mcp: { tool: { name: tool("env_reveal"), title: "Reveal one env var value (secret)", description: "Reveal one decrypted env var value. Secret-bearing — returns a credential, not a list." } }
|
|
@@ -249,15 +265,16 @@ function platformDomain(config) {
|
|
|
249
265
|
safety: "write",
|
|
250
266
|
auth: true,
|
|
251
267
|
title: `Set one ${noun} environment variable`,
|
|
252
|
-
description: `Create or update one env var
|
|
268
|
+
description: `Create or update one env var. Writes every environment unless you pass target.`,
|
|
253
269
|
input: [
|
|
254
270
|
idArg,
|
|
255
271
|
idField("key", "The env var key."),
|
|
256
|
-
{ name: "value", location: "body", type: "string", required: true, secret: true, description: "The env var value." }
|
|
272
|
+
{ name: "value", location: "body", type: "string", required: true, secret: true, description: "The env var value." },
|
|
273
|
+
envWriteTargetField
|
|
257
274
|
],
|
|
258
275
|
http: { method: "PUT", path: `${base}/{${resourceParam}}/env/{key}`, operationId: operationIds.envSet, bodyMode: "json" },
|
|
259
276
|
cli: { command: [domain, "env", "set"], summary: "Set one env var" },
|
|
260
|
-
mcp: { tool: { name: tool("env_set"), title: "Set one env var", description: "Create or update one env var
|
|
277
|
+
mcp: { tool: { name: tool("env_set"), title: "Set one env var", description: "Create or update one env var. Writes every environment unless target is provided." } }
|
|
261
278
|
},
|
|
262
279
|
{
|
|
263
280
|
id: `${cap}.env.unset`,
|
|
@@ -266,11 +283,20 @@ function platformDomain(config) {
|
|
|
266
283
|
safety: "destructive",
|
|
267
284
|
auth: true,
|
|
268
285
|
title: `Unset one ${noun} environment variable`,
|
|
269
|
-
description: `Delete one env var
|
|
270
|
-
input: [
|
|
286
|
+
description: `Delete one env var. Removes it from every environment unless you pass target.`,
|
|
287
|
+
input: [
|
|
288
|
+
idArg,
|
|
289
|
+
idField("key", "The env var key to delete."),
|
|
290
|
+
{
|
|
291
|
+
name: "target",
|
|
292
|
+
location: "query",
|
|
293
|
+
type: "string",
|
|
294
|
+
description: "Environment to remove from: production, preview, or development. Omit to remove from all environments."
|
|
295
|
+
}
|
|
296
|
+
],
|
|
271
297
|
http: { method: "DELETE", path: `${base}/{${resourceParam}}/env/{key}`, operationId: operationIds.envDelete },
|
|
272
298
|
cli: { command: [domain, "env", "unset"], summary: "Unset one env var" },
|
|
273
|
-
mcp: { tool: { name: tool("env_unset"), title: "Unset one env var", description: "Delete one env var
|
|
299
|
+
mcp: { tool: { name: tool("env_unset"), title: "Unset one env var", description: "Delete one env var. Removes it from every environment unless target is provided. Requires confirmation." } }
|
|
274
300
|
}
|
|
275
301
|
];
|
|
276
302
|
const resource = {
|
|
@@ -296,6 +322,71 @@ function platformDomain(config) {
|
|
|
296
322
|
};
|
|
297
323
|
return [...operations, resource];
|
|
298
324
|
}
|
|
325
|
+
function emailSubscriptionDomain(config) {
|
|
326
|
+
const { domain, noun, resourceParam, cap } = config;
|
|
327
|
+
const base = `/api/v1/${domain}/{${resourceParam}}/email-subscriptions`;
|
|
328
|
+
const idArg = idField(resourceParam, `The ${noun} id.`);
|
|
329
|
+
const subArg = idField("subscriptionId", "The email subscription id.");
|
|
330
|
+
const tool = (suffix) => `khotan_${domain}_email_subscriptions_${suffix}`;
|
|
331
|
+
return [
|
|
332
|
+
{
|
|
333
|
+
id: `${domain}.email-subscriptions.list`,
|
|
334
|
+
kind: "operation",
|
|
335
|
+
domain,
|
|
336
|
+
safety: "read",
|
|
337
|
+
auth: true,
|
|
338
|
+
title: `List ${noun} email subscriptions`,
|
|
339
|
+
description: `List the connected mailboxes this ${noun} watches.`,
|
|
340
|
+
input: [idArg],
|
|
341
|
+
http: { method: "GET", path: base, operationId: `list${cap}EmailSubscriptions` },
|
|
342
|
+
cli: { command: [domain, "email-subscriptions", "list"], summary: "List email subscriptions", defaultOutput: "table" },
|
|
343
|
+
mcp: { tool: { name: tool("list"), title: "List email subscriptions", description: `List the connected mailboxes this ${noun} watches.` } }
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
id: `${domain}.email-subscriptions.create`,
|
|
347
|
+
kind: "operation",
|
|
348
|
+
domain,
|
|
349
|
+
safety: "write",
|
|
350
|
+
auth: true,
|
|
351
|
+
title: `Subscribe ${article(noun)} ${noun} to a mailbox`,
|
|
352
|
+
description: `Watch a connected Gmail mailbox for this ${noun}; Google pushes notifications directly to the delivery URL.`,
|
|
353
|
+
input: [
|
|
354
|
+
idArg,
|
|
355
|
+
{ name: "emailIntegrationId", location: "body", type: "string", required: true, description: "The connected Gmail integration id." },
|
|
356
|
+
{ name: "deliveryUrl", location: "body", type: "string", required: true, description: "https URL on the target's primary hostname." }
|
|
357
|
+
],
|
|
358
|
+
http: { method: "POST", path: base, operationId: `create${cap}EmailSubscription`, bodyMode: "json" },
|
|
359
|
+
cli: { command: [domain, "email-subscriptions", "create"], summary: "Subscribe to a mailbox", defaultOutput: "detail" },
|
|
360
|
+
mcp: { tool: { name: tool("create"), title: "Subscribe to a mailbox", description: `Watch a connected Gmail mailbox for this ${noun}.` } }
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
id: `${domain}.email-subscriptions.get`,
|
|
364
|
+
kind: "operation",
|
|
365
|
+
domain,
|
|
366
|
+
safety: "read",
|
|
367
|
+
auth: true,
|
|
368
|
+
title: `Get ${article(noun)} ${noun} email subscription`,
|
|
369
|
+
description: "Get one email subscription by id.",
|
|
370
|
+
input: [idArg, subArg],
|
|
371
|
+
http: { method: "GET", path: `${base}/{subscriptionId}`, operationId: `get${cap}EmailSubscription` },
|
|
372
|
+
cli: { command: [domain, "email-subscriptions", "get"], summary: "Get an email subscription", defaultOutput: "detail" },
|
|
373
|
+
mcp: { tool: { name: tool("get"), title: "Get an email subscription", description: "Get one email subscription by id." } }
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
id: `${domain}.email-subscriptions.delete`,
|
|
377
|
+
kind: "operation",
|
|
378
|
+
domain,
|
|
379
|
+
safety: "destructive",
|
|
380
|
+
auth: true,
|
|
381
|
+
title: `Unsubscribe ${article(noun)} ${noun} from a mailbox`,
|
|
382
|
+
description: "Remove an email subscription (deletes the Pub/Sub push subscription).",
|
|
383
|
+
input: [idArg, subArg],
|
|
384
|
+
http: { method: "DELETE", path: `${base}/{subscriptionId}`, operationId: `delete${cap}EmailSubscription` },
|
|
385
|
+
cli: { command: [domain, "email-subscriptions", "delete"], summary: "Unsubscribe from a mailbox" },
|
|
386
|
+
mcp: { tool: { name: tool("delete"), title: "Unsubscribe from a mailbox", description: "Remove an email subscription. Requires confirmation." } }
|
|
387
|
+
}
|
|
388
|
+
];
|
|
389
|
+
}
|
|
299
390
|
function getCapability(id) {
|
|
300
391
|
return capabilitiesById.get(id);
|
|
301
392
|
}
|
|
@@ -317,7 +408,7 @@ var idField = (name, description) => ({
|
|
|
317
408
|
type: "string",
|
|
318
409
|
required: true,
|
|
319
410
|
description
|
|
320
|
-
}), article = (noun) => /^[aeiou]/i.test(noun) ? "an" : "a", identityCapabilities, databaseCapabilities, fileCapabilities, folderCapabilities, contextCapabilities, appsCapabilities, pipelinesCapabilities, capabilities, khotanCatalog, capabilitiesById;
|
|
411
|
+
}), article = (noun) => /^[aeiou]/i.test(noun) ? "an" : "a", identityCapabilities, databaseCapabilities, redisCapabilities, fileCapabilities, folderCapabilities, contextCapabilities, appsCapabilities, pipelinesCapabilities, integrationsCapabilities, emailSubscriptionCapabilities, capabilities, khotanCatalog, capabilitiesById;
|
|
321
412
|
var init_catalog = __esm(() => {
|
|
322
413
|
init_version();
|
|
323
414
|
identityCapabilities = [
|
|
@@ -556,6 +647,54 @@ var init_catalog = __esm(() => {
|
|
|
556
647
|
cli: { command: ["databases", "branches", "rotate-credentials"], summary: "Rotate branch credentials (secret)", defaultOutput: "raw" },
|
|
557
648
|
mcp: { tool: { name: "khotan_databases_branches_rotate_credentials", title: "Rotate branch credentials (secret)", description: "Rotate a branch's credentials and return the new connection. Secret-bearing." } }
|
|
558
649
|
},
|
|
650
|
+
{
|
|
651
|
+
id: "databases.schema-promotions.plan",
|
|
652
|
+
kind: "operation",
|
|
653
|
+
domain: "databases",
|
|
654
|
+
safety: "write",
|
|
655
|
+
auth: true,
|
|
656
|
+
title: "Plan a database schema promotion",
|
|
657
|
+
description: "Generate and validate a schema-only promotion plan from a ready branch to the production database. Data is never copied.",
|
|
658
|
+
input: [
|
|
659
|
+
idField("databaseId", "The database id."),
|
|
660
|
+
idField("branchId", "The branch id.")
|
|
661
|
+
],
|
|
662
|
+
http: { method: "POST", path: "/api/v1/databases/{databaseId}/branches/{branchId}/schema-promotions", operationId: "planDatabaseSchemaPromotion", bodyMode: "none" },
|
|
663
|
+
cli: { command: ["databases", "schema-promotions", "plan"], summary: "Plan a schema-only promotion", defaultOutput: "detail" },
|
|
664
|
+
mcp: { tool: { name: "khotan_databases_schema_promotions_plan", title: "Plan a schema promotion", description: "Generate and validate a schema-only promotion plan from a ready branch to production. Data is never copied." } }
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
id: "databases.schema-promotions.get",
|
|
668
|
+
kind: "operation",
|
|
669
|
+
domain: "databases",
|
|
670
|
+
safety: "read",
|
|
671
|
+
auth: true,
|
|
672
|
+
title: "Get a database schema promotion",
|
|
673
|
+
description: "Read one schema promotion record, including summary, blocked changes, failure state, and SQL.",
|
|
674
|
+
input: [
|
|
675
|
+
idField("databaseId", "The database id."),
|
|
676
|
+
idField("promotionId", "The schema promotion id.")
|
|
677
|
+
],
|
|
678
|
+
http: { method: "GET", path: "/api/v1/databases/{databaseId}/schema-promotions/{promotionId}", operationId: "getDatabaseSchemaPromotion" },
|
|
679
|
+
cli: { command: ["databases", "schema-promotions", "get"], summary: "Get a schema promotion", defaultOutput: "detail" },
|
|
680
|
+
mcp: { tool: { name: "khotan_databases_schema_promotions_get", title: "Get a schema promotion", description: "Read one schema promotion record, including generated SQL." } }
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
id: "databases.schema-promotions.apply",
|
|
684
|
+
kind: "operation",
|
|
685
|
+
domain: "databases",
|
|
686
|
+
safety: "destructive",
|
|
687
|
+
auth: true,
|
|
688
|
+
title: "Apply a database schema promotion",
|
|
689
|
+
description: "Apply the exact reviewed schema-only promotion SQL to the production database. Fails if branch or production schema hashes changed after planning.",
|
|
690
|
+
input: [
|
|
691
|
+
idField("databaseId", "The database id."),
|
|
692
|
+
idField("promotionId", "The schema promotion id.")
|
|
693
|
+
],
|
|
694
|
+
http: { method: "POST", path: "/api/v1/databases/{databaseId}/schema-promotions/{promotionId}/apply", operationId: "applyDatabaseSchemaPromotion", bodyMode: "none" },
|
|
695
|
+
cli: { command: ["databases", "schema-promotions", "apply"], summary: "Apply a schema promotion" },
|
|
696
|
+
mcp: { tool: { name: "khotan_databases_schema_promotions_apply", title: "Apply a schema promotion", description: "Apply reviewed schema-only SQL to production. Requires confirmation." } }
|
|
697
|
+
},
|
|
559
698
|
{
|
|
560
699
|
id: "databases.branches.resource",
|
|
561
700
|
kind: "resource",
|
|
@@ -581,6 +720,84 @@ var init_catalog = __esm(() => {
|
|
|
581
720
|
}
|
|
582
721
|
}
|
|
583
722
|
];
|
|
723
|
+
redisCapabilities = [
|
|
724
|
+
{
|
|
725
|
+
id: "redis.list",
|
|
726
|
+
kind: "operation",
|
|
727
|
+
domain: "redis",
|
|
728
|
+
safety: "read",
|
|
729
|
+
auth: true,
|
|
730
|
+
title: "List Redis databases",
|
|
731
|
+
description: "List the organization's Redis (KV) databases (no credentials).",
|
|
732
|
+
input: [],
|
|
733
|
+
http: { method: "GET", path: "/api/v1/redis-databases", operationId: "listRedisDatabases" },
|
|
734
|
+
cli: { command: ["redis", "list"], summary: "List Redis (KV) databases", defaultOutput: "table" },
|
|
735
|
+
mcp: { tool: { name: "khotan_redis_list", title: "List Redis databases", description: "List the organization's Redis (KV) databases (no credentials)." } }
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
id: "redis.get",
|
|
739
|
+
kind: "operation",
|
|
740
|
+
domain: "redis",
|
|
741
|
+
safety: "read",
|
|
742
|
+
auth: true,
|
|
743
|
+
title: "Get a Redis database",
|
|
744
|
+
description: "Get one Redis (KV) database by id (no credentials).",
|
|
745
|
+
input: [idField("databaseId", "The Redis database id.")],
|
|
746
|
+
http: { method: "GET", path: "/api/v1/redis-databases/{databaseId}", operationId: "getRedisDatabase" },
|
|
747
|
+
cli: { command: ["redis", "get"], summary: "Get a Redis (KV) database", defaultOutput: "detail" },
|
|
748
|
+
mcp: { tool: { name: "khotan_redis_get", title: "Get a Redis database", description: "Get one Redis (KV) database by id (no credentials)." } }
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
id: "redis.create",
|
|
752
|
+
kind: "operation",
|
|
753
|
+
domain: "redis",
|
|
754
|
+
safety: "write",
|
|
755
|
+
auth: true,
|
|
756
|
+
title: "Create a Redis database",
|
|
757
|
+
description: "Provision a global Upstash Redis (KV) database. Not idempotent: retrying a succeeded create provisions a second database. Omit primaryRegion for the default region.",
|
|
758
|
+
input: [
|
|
759
|
+
{ name: "name", location: "body", type: "string", required: true, description: "The database name." },
|
|
760
|
+
{ name: "primaryRegion", location: "body", type: "string", description: "Optional primary region id; omit for the default region." }
|
|
761
|
+
],
|
|
762
|
+
http: { method: "POST", path: "/api/v1/redis-databases", operationId: "createRedisDatabase", bodyMode: "json" },
|
|
763
|
+
cli: { command: ["redis", "create"], summary: "Create a Redis (KV) database", defaultOutput: "detail" },
|
|
764
|
+
mcp: { tool: { name: "khotan_redis_create", title: "Create a Redis database", description: "Provision a global Upstash Redis (KV) database. Not idempotent — retrying provisions a second database." } }
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
id: "redis.delete",
|
|
768
|
+
kind: "operation",
|
|
769
|
+
domain: "redis",
|
|
770
|
+
safety: "destructive",
|
|
771
|
+
auth: true,
|
|
772
|
+
title: "Delete a Redis database",
|
|
773
|
+
description: "Permanently delete a Redis (KV) database and tear down its Upstash resources. Fails if deletion protection is enabled; remove protection from the dashboard first.",
|
|
774
|
+
input: [idField("databaseId", "The Redis database id.")],
|
|
775
|
+
http: { method: "DELETE", path: "/api/v1/redis-databases/{databaseId}", operationId: "deleteRedisDatabase" },
|
|
776
|
+
cli: { command: ["redis", "delete"], summary: "Delete a Redis (KV) database" },
|
|
777
|
+
mcp: { tool: { name: "khotan_redis_delete", title: "Delete a Redis database", description: "Permanently delete a Redis (KV) database. Requires confirmation; fails if deletion protection is enabled." } }
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
id: "redis.resource",
|
|
781
|
+
kind: "resource",
|
|
782
|
+
domain: "redis",
|
|
783
|
+
safety: "read",
|
|
784
|
+
auth: true,
|
|
785
|
+
title: "Redis database summary",
|
|
786
|
+
description: "Credential-free Redis (KV) database summary addressed by id.",
|
|
787
|
+
input: [idField("databaseId", "The Redis database id.")],
|
|
788
|
+
readsVia: "redis.get",
|
|
789
|
+
http: { method: "GET", path: "/api/v1/redis-databases/{databaseId}", operationId: "getRedisDatabase" },
|
|
790
|
+
mcp: {
|
|
791
|
+
resource: {
|
|
792
|
+
uriTemplate: "khotan://redis/{databaseId}",
|
|
793
|
+
name: "redis-database-summary",
|
|
794
|
+
title: "Redis database summary",
|
|
795
|
+
description: "Credential-free Redis (KV) database summary as JSON.",
|
|
796
|
+
mimeType: "application/json"
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
];
|
|
584
801
|
fileCapabilities = [
|
|
585
802
|
{
|
|
586
803
|
id: "files.list",
|
|
@@ -947,14 +1164,127 @@ var init_catalog = __esm(() => {
|
|
|
947
1164
|
envDelete: "deletePipelineEnvVar"
|
|
948
1165
|
}
|
|
949
1166
|
});
|
|
1167
|
+
integrationsCapabilities = [
|
|
1168
|
+
{
|
|
1169
|
+
id: "integrations.list",
|
|
1170
|
+
kind: "operation",
|
|
1171
|
+
domain: "integrations",
|
|
1172
|
+
safety: "read",
|
|
1173
|
+
auth: true,
|
|
1174
|
+
title: "List integrations",
|
|
1175
|
+
description: "List the organization's connected mailbox integrations (no token material).",
|
|
1176
|
+
input: [],
|
|
1177
|
+
http: { method: "GET", path: "/api/v1/integrations", operationId: "listIntegrations" },
|
|
1178
|
+
cli: { command: ["integrations", "list"], summary: "List integrations", defaultOutput: "table" },
|
|
1179
|
+
mcp: { tool: { name: "khotan_integrations_list", title: "List integrations", description: "List the organization's connected mailbox integrations (no token material)." } }
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
id: "integrations.get",
|
|
1183
|
+
kind: "operation",
|
|
1184
|
+
domain: "integrations",
|
|
1185
|
+
safety: "read",
|
|
1186
|
+
auth: true,
|
|
1187
|
+
title: "Get an integration",
|
|
1188
|
+
description: "Get one integration by id (no token material).",
|
|
1189
|
+
input: [idField("integrationId", "The integration id.")],
|
|
1190
|
+
http: { method: "GET", path: "/api/v1/integrations/{integrationId}", operationId: "getIntegration" },
|
|
1191
|
+
cli: { command: ["integrations", "get"], summary: "Get an integration", defaultOutput: "detail" },
|
|
1192
|
+
mcp: { tool: { name: "khotan_integrations_get", title: "Get an integration", description: "Get one integration by id (no token material)." } }
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
id: "integrations.connect",
|
|
1196
|
+
kind: "operation",
|
|
1197
|
+
domain: "integrations",
|
|
1198
|
+
safety: "write",
|
|
1199
|
+
auth: true,
|
|
1200
|
+
title: "Start connecting an integration",
|
|
1201
|
+
description: "Begin a mailbox connection and return a URL a human opens in a browser to authorize (OAuth consent cannot be completed headlessly).",
|
|
1202
|
+
input: [
|
|
1203
|
+
{ name: "provider", location: "body", type: "string", description: "The provider to connect. Defaults to google.", default: "google" }
|
|
1204
|
+
],
|
|
1205
|
+
http: { method: "POST", path: "/api/v1/integrations", operationId: "connectIntegration", bodyMode: "json" },
|
|
1206
|
+
cli: { command: ["integrations", "connect"], summary: "Start connecting a mailbox", defaultOutput: "detail" },
|
|
1207
|
+
mcp: { tool: { name: "khotan_integrations_connect", title: "Start connecting an integration", description: "Begin a mailbox connection and return a browser URL to complete OAuth consent." } }
|
|
1208
|
+
},
|
|
1209
|
+
{
|
|
1210
|
+
id: "integrations.rename",
|
|
1211
|
+
kind: "operation",
|
|
1212
|
+
domain: "integrations",
|
|
1213
|
+
safety: "write",
|
|
1214
|
+
auth: true,
|
|
1215
|
+
title: "Rename an integration",
|
|
1216
|
+
description: "Set an integration's display name. Pass an empty value to clear it.",
|
|
1217
|
+
input: [
|
|
1218
|
+
idField("integrationId", "The integration id."),
|
|
1219
|
+
{ name: "name", location: "body", type: "string", required: true, description: "The display name (empty to clear)." }
|
|
1220
|
+
],
|
|
1221
|
+
http: { method: "PATCH", path: "/api/v1/integrations/{integrationId}", operationId: "updateIntegration", bodyMode: "json" },
|
|
1222
|
+
cli: { command: ["integrations", "rename"], summary: "Rename an integration", defaultOutput: "detail" },
|
|
1223
|
+
mcp: { tool: { name: "khotan_integrations_rename", title: "Rename an integration", description: "Set an integration's display name." } }
|
|
1224
|
+
},
|
|
1225
|
+
{
|
|
1226
|
+
id: "integrations.delete",
|
|
1227
|
+
kind: "operation",
|
|
1228
|
+
domain: "integrations",
|
|
1229
|
+
safety: "destructive",
|
|
1230
|
+
auth: true,
|
|
1231
|
+
title: "Disconnect an integration",
|
|
1232
|
+
description: "Permanently disconnect a mailbox integration (revokes the stored credentials).",
|
|
1233
|
+
input: [idField("integrationId", "The integration id.")],
|
|
1234
|
+
http: { method: "DELETE", path: "/api/v1/integrations/{integrationId}", operationId: "deleteIntegration" },
|
|
1235
|
+
cli: { command: ["integrations", "delete"], summary: "Disconnect an integration" },
|
|
1236
|
+
mcp: { tool: { name: "khotan_integrations_delete", title: "Disconnect an integration", description: "Permanently disconnect a mailbox integration. Requires confirmation." } }
|
|
1237
|
+
},
|
|
1238
|
+
{
|
|
1239
|
+
id: "integrations.token",
|
|
1240
|
+
kind: "operation",
|
|
1241
|
+
domain: "integrations",
|
|
1242
|
+
safety: "secret",
|
|
1243
|
+
auth: true,
|
|
1244
|
+
title: "Mint an access token (secret)",
|
|
1245
|
+
description: "Mint a fresh OAuth access token for the integration so a service can call the provider on the user's behalf. Secret-bearing; explicit-only.",
|
|
1246
|
+
input: [idField("integrationId", "The integration id.")],
|
|
1247
|
+
http: { method: "POST", path: "/api/v1/integrations/{integrationId}/token", operationId: "mintIntegrationToken", bodyMode: "none" },
|
|
1248
|
+
cli: { command: ["integrations", "token"], summary: "Mint an access token (secret)", defaultOutput: "detail" },
|
|
1249
|
+
mcp: { tool: { name: "khotan_integrations_token", title: "Mint an access token (secret)", description: "Mint a fresh OAuth access token for the integration. Secret-bearing — returns a credential." } }
|
|
1250
|
+
},
|
|
1251
|
+
{
|
|
1252
|
+
id: "integrations.resource",
|
|
1253
|
+
kind: "resource",
|
|
1254
|
+
domain: "integrations",
|
|
1255
|
+
safety: "read",
|
|
1256
|
+
auth: true,
|
|
1257
|
+
title: "Integration summary",
|
|
1258
|
+
description: "Credential-free integration summary addressed by id.",
|
|
1259
|
+
input: [idField("integrationId", "The integration id.")],
|
|
1260
|
+
readsVia: "integrations.get",
|
|
1261
|
+
http: { method: "GET", path: "/api/v1/integrations/{integrationId}", operationId: "getIntegration" },
|
|
1262
|
+
mcp: {
|
|
1263
|
+
resource: {
|
|
1264
|
+
uriTemplate: "khotan://integrations/{integrationId}",
|
|
1265
|
+
name: "integration-summary",
|
|
1266
|
+
title: "Integration summary",
|
|
1267
|
+
description: "Credential-free integration summary as JSON.",
|
|
1268
|
+
mimeType: "application/json"
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
];
|
|
1273
|
+
emailSubscriptionCapabilities = [
|
|
1274
|
+
...emailSubscriptionDomain({ domain: "apps", noun: "deployment", resourceParam: "appId", cap: "App" }),
|
|
1275
|
+
...emailSubscriptionDomain({ domain: "pipelines", noun: "pipeline", resourceParam: "pipelineId", cap: "Pipeline" })
|
|
1276
|
+
];
|
|
950
1277
|
capabilities = [
|
|
951
1278
|
...identityCapabilities,
|
|
952
1279
|
...appsCapabilities,
|
|
953
1280
|
...pipelinesCapabilities,
|
|
954
1281
|
...databaseCapabilities,
|
|
1282
|
+
...redisCapabilities,
|
|
955
1283
|
...fileCapabilities,
|
|
956
1284
|
...folderCapabilities,
|
|
957
|
-
...contextCapabilities
|
|
1285
|
+
...contextCapabilities,
|
|
1286
|
+
...integrationsCapabilities,
|
|
1287
|
+
...emailSubscriptionCapabilities
|
|
958
1288
|
];
|
|
959
1289
|
khotanCatalog = {
|
|
960
1290
|
schemaVersion: CATALOG_SCHEMA_VERSION,
|
|
@@ -1315,6 +1645,66 @@ var init_profiles = __esm(() => {
|
|
|
1315
1645
|
init_errors();
|
|
1316
1646
|
});
|
|
1317
1647
|
|
|
1648
|
+
// ../khotan-core/src/profiles/repo-env.ts
|
|
1649
|
+
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
1650
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
1651
|
+
function findRepoEnvFile(startDir) {
|
|
1652
|
+
let dir = startDir;
|
|
1653
|
+
for (;; ) {
|
|
1654
|
+
const candidate = join2(dir, REPO_ENV_FILENAME);
|
|
1655
|
+
if (existsSync(candidate))
|
|
1656
|
+
return candidate;
|
|
1657
|
+
if (existsSync(join2(dir, ".git")))
|
|
1658
|
+
return;
|
|
1659
|
+
const parent = dirname2(dir);
|
|
1660
|
+
if (parent === dir)
|
|
1661
|
+
return;
|
|
1662
|
+
dir = parent;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
function parseRepoEnvFile(contents) {
|
|
1666
|
+
const map = new Map;
|
|
1667
|
+
for (const rawLine of contents.split(`
|
|
1668
|
+
`)) {
|
|
1669
|
+
const line = rawLine.trim();
|
|
1670
|
+
if (!line || line.startsWith("#"))
|
|
1671
|
+
continue;
|
|
1672
|
+
const eq = line.indexOf("=");
|
|
1673
|
+
if (eq === -1)
|
|
1674
|
+
continue;
|
|
1675
|
+
const key = line.slice(0, eq).trim();
|
|
1676
|
+
let value = line.slice(eq + 1).trim();
|
|
1677
|
+
if (value.startsWith("'") && value.endsWith("'") || value.startsWith('"') && value.endsWith('"')) {
|
|
1678
|
+
value = value.slice(1, -1);
|
|
1679
|
+
}
|
|
1680
|
+
map.set(key, value);
|
|
1681
|
+
}
|
|
1682
|
+
return map;
|
|
1683
|
+
}
|
|
1684
|
+
function overlayRepoEnv(env, options) {
|
|
1685
|
+
const filePath = findRepoEnvFile(options.cwd);
|
|
1686
|
+
if (!filePath)
|
|
1687
|
+
return { env };
|
|
1688
|
+
let parsed;
|
|
1689
|
+
try {
|
|
1690
|
+
parsed = parseRepoEnvFile(readFileSync2(filePath, "utf8"));
|
|
1691
|
+
} catch {
|
|
1692
|
+
return { env, filePath };
|
|
1693
|
+
}
|
|
1694
|
+
const merged = { ...env };
|
|
1695
|
+
for (const key of OVERLAY_KEYS) {
|
|
1696
|
+
const value = parsed.get(key)?.trim();
|
|
1697
|
+
if (value)
|
|
1698
|
+
merged[key] = value;
|
|
1699
|
+
}
|
|
1700
|
+
return { env: merged, filePath, orgId: merged[ENV_ORG_ID]?.trim() || undefined };
|
|
1701
|
+
}
|
|
1702
|
+
var ENV_ORG_ID = "KHOTAN_ORG_ID", REPO_ENV_FILENAME = "env.khotan.local", OVERLAY_KEYS;
|
|
1703
|
+
var init_repo_env = __esm(() => {
|
|
1704
|
+
init_profiles();
|
|
1705
|
+
OVERLAY_KEYS = [ENV_API_URL, ENV_API_KEY, ENV_PROFILE, ENV_ORG_ID];
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1318
1708
|
// ../khotan-core/src/index.ts
|
|
1319
1709
|
var exports_src = {};
|
|
1320
1710
|
__export(exports_src, {
|
|
@@ -1325,6 +1715,8 @@ __export(exports_src, {
|
|
|
1325
1715
|
saveProfileStore: () => saveProfileStore,
|
|
1326
1716
|
resolveProfile: () => resolveProfile,
|
|
1327
1717
|
requiresConfirmation: () => requiresConfirmation,
|
|
1718
|
+
parseRepoEnvFile: () => parseRepoEnvFile,
|
|
1719
|
+
overlayRepoEnv: () => overlayRepoEnv,
|
|
1328
1720
|
loadProfileStore: () => loadProfileStore,
|
|
1329
1721
|
listDomains: () => listDomains,
|
|
1330
1722
|
listCapabilities: () => listCapabilities,
|
|
@@ -1338,12 +1730,14 @@ __export(exports_src, {
|
|
|
1338
1730
|
indexOpenApiOperations: () => indexOpenApiOperations,
|
|
1339
1731
|
getProfileStorePath: () => getProfileStorePath,
|
|
1340
1732
|
getCapability: () => getCapability,
|
|
1733
|
+
findRepoEnvFile: () => findRepoEnvFile,
|
|
1341
1734
|
createApiClient: () => createApiClient,
|
|
1342
1735
|
capabilitiesByOperationId: () => capabilitiesByOperationId,
|
|
1343
1736
|
buildHttpRequest: () => buildHttpRequest,
|
|
1344
1737
|
assertSupportedCatalogSchemaVersion: () => assertSupportedCatalogSchemaVersion,
|
|
1345
1738
|
SUPPORTED_CATALOG_SCHEMA_VERSIONS: () => SUPPORTED_CATALOG_SCHEMA_VERSIONS,
|
|
1346
1739
|
SAFETY_LEVELS: () => SAFETY_LEVELS,
|
|
1740
|
+
REPO_ENV_FILENAME: () => REPO_ENV_FILENAME,
|
|
1347
1741
|
KhotanUnknownCapabilityError: () => KhotanUnknownCapabilityError,
|
|
1348
1742
|
KhotanError: () => KhotanError,
|
|
1349
1743
|
KhotanConfirmationRequiredError: () => KhotanConfirmationRequiredError,
|
|
@@ -1353,6 +1747,7 @@ __export(exports_src, {
|
|
|
1353
1747
|
KHOTAN_ADAPTER_VERSION: () => KHOTAN_ADAPTER_VERSION,
|
|
1354
1748
|
KHOTAN_ADAPTER_NAME: () => KHOTAN_ADAPTER_NAME,
|
|
1355
1749
|
ENV_PROFILE: () => ENV_PROFILE,
|
|
1750
|
+
ENV_ORG_ID: () => ENV_ORG_ID,
|
|
1356
1751
|
ENV_API_URL: () => ENV_API_URL,
|
|
1357
1752
|
ENV_API_KEY: () => ENV_API_KEY,
|
|
1358
1753
|
CATALOG_VERSION: () => CATALOG_VERSION,
|
|
@@ -1366,6 +1761,7 @@ var init_src = __esm(() => {
|
|
|
1366
1761
|
init_validate();
|
|
1367
1762
|
init_api_client();
|
|
1368
1763
|
init_profiles();
|
|
1764
|
+
init_repo_env();
|
|
1369
1765
|
});
|
|
1370
1766
|
|
|
1371
1767
|
// src/cli/io.ts
|
|
@@ -1402,6 +1798,65 @@ function errLine(io, line = "") {
|
|
|
1402
1798
|
}
|
|
1403
1799
|
var init_io = () => {};
|
|
1404
1800
|
|
|
1801
|
+
// src/cli/assert-org.ts
|
|
1802
|
+
import { createHash } from "node:crypto";
|
|
1803
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
1804
|
+
import { homedir as homedir2 } from "node:os";
|
|
1805
|
+
import { dirname as dirname4, join as join5 } from "node:path";
|
|
1806
|
+
function cacheDir(env) {
|
|
1807
|
+
const xdg = env.XDG_CACHE_HOME;
|
|
1808
|
+
const base = xdg && xdg.trim() ? xdg : join5(homedir2(), ".cache");
|
|
1809
|
+
return join5(base, "khotan");
|
|
1810
|
+
}
|
|
1811
|
+
function cacheFilePath(env, apiUrl, orgId) {
|
|
1812
|
+
const hash = createHash("sha256").update(`${apiUrl}
|
|
1813
|
+
${orgId}`).digest("hex").slice(0, 32);
|
|
1814
|
+
return join5(cacheDir(env), `org-assert-${hash}.json`);
|
|
1815
|
+
}
|
|
1816
|
+
function readAssertionCache(path, expectedOrgId) {
|
|
1817
|
+
if (!existsSync3(path))
|
|
1818
|
+
return false;
|
|
1819
|
+
try {
|
|
1820
|
+
const parsed = JSON.parse(readFileSync4(path, "utf8"));
|
|
1821
|
+
if (parsed.organizationId !== expectedOrgId)
|
|
1822
|
+
return false;
|
|
1823
|
+
if (Date.now() - parsed.verifiedAt > CACHE_TTL_MS)
|
|
1824
|
+
return false;
|
|
1825
|
+
return true;
|
|
1826
|
+
} catch {
|
|
1827
|
+
return false;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
function writeAssertionCache(path, organizationId) {
|
|
1831
|
+
try {
|
|
1832
|
+
mkdirSync3(dirname4(path), { recursive: true });
|
|
1833
|
+
const payload = { organizationId, verifiedAt: Date.now() };
|
|
1834
|
+
writeFileSync4(path, `${JSON.stringify(payload)}
|
|
1835
|
+
`);
|
|
1836
|
+
} catch {}
|
|
1837
|
+
}
|
|
1838
|
+
async function assertOrg(session, expectedOrgId, options = {}) {
|
|
1839
|
+
const expected = expectedOrgId.trim();
|
|
1840
|
+
if (!expected)
|
|
1841
|
+
return;
|
|
1842
|
+
const env = options.env ?? process.env;
|
|
1843
|
+
const cachePath = cacheFilePath(env, session.apiUrl, expected);
|
|
1844
|
+
if (!options.skipCache && readAssertionCache(cachePath, expected)) {
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
const principal = await session.client.execute("identity.whoami");
|
|
1848
|
+
const actual = String(principal.organizationId ?? "");
|
|
1849
|
+
if (actual !== expected) {
|
|
1850
|
+
throw new KhotanClientError("org_mismatch", `Khotan org mismatch: expected organizationId=${expected} (KHOTAN_ORG_ID / --assert-org), ` + `but whoami returned organizationId=${actual || "(empty)"}. ` + `You may be authenticated to a different customer's org. ` + `Fix credentials in env.khotan.local, pass --assert-org, or select a per-customer profile.`);
|
|
1851
|
+
}
|
|
1852
|
+
writeAssertionCache(cachePath, actual);
|
|
1853
|
+
}
|
|
1854
|
+
var CACHE_TTL_MS;
|
|
1855
|
+
var init_assert_org = __esm(() => {
|
|
1856
|
+
init_src();
|
|
1857
|
+
CACHE_TTL_MS = 5 * 60 * 1000;
|
|
1858
|
+
});
|
|
1859
|
+
|
|
1405
1860
|
// src/cli/session.ts
|
|
1406
1861
|
function resolveSession(options) {
|
|
1407
1862
|
const resolved = resolveProfile({
|
|
@@ -1421,8 +1876,17 @@ function resolveSession(options) {
|
|
|
1421
1876
|
apiKey
|
|
1422
1877
|
};
|
|
1423
1878
|
}
|
|
1879
|
+
async function resolveSessionAsserted(options) {
|
|
1880
|
+
const session = resolveSession(options);
|
|
1881
|
+
const expectedOrgId = options.assertOrgOverride?.trim() || options.env[ENV_ORG_ID]?.trim();
|
|
1882
|
+
if (expectedOrgId) {
|
|
1883
|
+
await assertOrg(session, expectedOrgId, { env: options.env });
|
|
1884
|
+
}
|
|
1885
|
+
return session;
|
|
1886
|
+
}
|
|
1424
1887
|
var init_session = __esm(() => {
|
|
1425
1888
|
init_src();
|
|
1889
|
+
init_assert_org();
|
|
1426
1890
|
});
|
|
1427
1891
|
|
|
1428
1892
|
// src/mcp/protocol.ts
|
|
@@ -1673,13 +2137,14 @@ __export(exports_serve, {
|
|
|
1673
2137
|
import { createInterface as createInterface2 } from "node:readline";
|
|
1674
2138
|
async function serveMcp(deps) {
|
|
1675
2139
|
assertSupportedCatalogSchemaVersion();
|
|
1676
|
-
const session =
|
|
2140
|
+
const session = await resolveSessionAsserted({
|
|
1677
2141
|
env: deps.env,
|
|
1678
2142
|
storeOptions: deps.storeOptions,
|
|
1679
2143
|
profileName: deps.profileName,
|
|
1680
2144
|
apiUrlOverride: deps.apiUrlOverride,
|
|
1681
2145
|
apiKeyOverride: deps.apiKeyOverride,
|
|
1682
|
-
fetch: deps.fetch
|
|
2146
|
+
fetch: deps.fetch,
|
|
2147
|
+
assertOrgOverride: deps.assertOrgOverride
|
|
1683
2148
|
});
|
|
1684
2149
|
if (!session.apiKey) {
|
|
1685
2150
|
throw new KhotanClientError("not_authenticated", "The MCP server needs an API key. Run `khotan auth set-key` or set KHOTAN_API_KEY.");
|
|
@@ -2053,8 +2518,8 @@ async function downloadFile(deps, options) {
|
|
|
2053
2518
|
|
|
2054
2519
|
// src/cli/commands/init.ts
|
|
2055
2520
|
init_io();
|
|
2056
|
-
import { existsSync, mkdirSync as mkdirSync2, readFileSync as
|
|
2057
|
-
import { dirname as
|
|
2521
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2522
|
+
import { dirname as dirname3, join as join3, relative } from "node:path";
|
|
2058
2523
|
|
|
2059
2524
|
// src/cli/skills.ts
|
|
2060
2525
|
init_src();
|
|
@@ -2161,9 +2626,11 @@ var DOMAIN_TITLES = {
|
|
|
2161
2626
|
apps: "Apps",
|
|
2162
2627
|
pipelines: "Pipelines",
|
|
2163
2628
|
databases: "Databases",
|
|
2629
|
+
redis: "Redis (KV)",
|
|
2164
2630
|
files: "Files",
|
|
2165
2631
|
folders: "Folders",
|
|
2166
|
-
context: "Context documents"
|
|
2632
|
+
context: "Context documents",
|
|
2633
|
+
integrations: "Integrations"
|
|
2167
2634
|
};
|
|
2168
2635
|
function commandWords(capability) {
|
|
2169
2636
|
return capability.cli?.command.join(" ");
|
|
@@ -2229,6 +2696,8 @@ function printTopLevelHelp(io) {
|
|
|
2229
2696
|
outLine(io, " files download <fileId> Download a file to --output (presigned flow)");
|
|
2230
2697
|
outLine(io);
|
|
2231
2698
|
outLine(io, "Workspace:");
|
|
2699
|
+
outLine(io, " status Show the workspace topology (apps, pipelines, databases)");
|
|
2700
|
+
outLine(io, " status --write Refresh a committable WORKSPACE.md from live data");
|
|
2232
2701
|
outLine(io, " init Scaffold MCP config + agent guidance into this workspace");
|
|
2233
2702
|
outLine(io);
|
|
2234
2703
|
outLine(io, "Agent:");
|
|
@@ -2240,10 +2709,33 @@ function printTopLevelHelp(io) {
|
|
|
2240
2709
|
outLine(io, " --profile <name> Use a specific stored profile");
|
|
2241
2710
|
outLine(io, " --api-url <url> Override the API origin");
|
|
2242
2711
|
outLine(io, " --api-key <key> Override the API key");
|
|
2712
|
+
outLine(io, " --assert-org <id> Require whoami.organizationId to equal <id> (fail closed)");
|
|
2243
2713
|
outLine(io, " --help Show help for a command");
|
|
2244
2714
|
outLine(io);
|
|
2245
2715
|
outLine(io, `khotan v${KHOTAN_ADAPTER_VERSION}`);
|
|
2246
2716
|
}
|
|
2717
|
+
function groupCommands(prefix) {
|
|
2718
|
+
if (prefix.length === 0) {
|
|
2719
|
+
return [];
|
|
2720
|
+
}
|
|
2721
|
+
return listCapabilities().filter((capability) => {
|
|
2722
|
+
if (!isOperationCapability(capability) || !capability.cli) {
|
|
2723
|
+
return false;
|
|
2724
|
+
}
|
|
2725
|
+
const words = capability.cli.command;
|
|
2726
|
+
return words.length > prefix.length && prefix.every((word, index) => words[index] === word);
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
function printGroupHelp(io, prefix, commands) {
|
|
2730
|
+
outLine(io, `khotan ${prefix.join(" ")} — commands`);
|
|
2731
|
+
outLine(io);
|
|
2732
|
+
for (const capability of commands) {
|
|
2733
|
+
const words = commandWords(capability) ?? capability.id;
|
|
2734
|
+
outLine(io, ` ${words.padEnd(30)} ${capability.cli?.summary ?? capability.title}`);
|
|
2735
|
+
}
|
|
2736
|
+
outLine(io);
|
|
2737
|
+
outLine(io, "Run `khotan <command> --help` for details.");
|
|
2738
|
+
}
|
|
2247
2739
|
function printCommandHelp(io, capability) {
|
|
2248
2740
|
outLine(io, capability.title);
|
|
2249
2741
|
outLine(io);
|
|
@@ -2276,6 +2768,7 @@ var DOMAIN_TITLES2 = {
|
|
|
2276
2768
|
apps: "Apps",
|
|
2277
2769
|
pipelines: "Pipelines",
|
|
2278
2770
|
databases: "Databases",
|
|
2771
|
+
redis: "Redis (KV)",
|
|
2279
2772
|
files: "Files",
|
|
2280
2773
|
folders: "Folders",
|
|
2281
2774
|
context: "Context documents"
|
|
@@ -2284,6 +2777,7 @@ var DOMAIN_LABELS = {
|
|
|
2284
2777
|
apps: "apps",
|
|
2285
2778
|
pipelines: "pipelines",
|
|
2286
2779
|
databases: "databases and database branches",
|
|
2780
|
+
redis: "Redis (KV) databases",
|
|
2287
2781
|
files: "files",
|
|
2288
2782
|
folders: "folders",
|
|
2289
2783
|
context: "context documents"
|
|
@@ -2458,32 +2952,54 @@ var SKILL_BASES = [".cursor", ".agents", ".claude"];
|
|
|
2458
2952
|
function mcpServerEntry() {
|
|
2459
2953
|
return {
|
|
2460
2954
|
command: "khotan",
|
|
2461
|
-
args: ["mcp", "serve"]
|
|
2462
|
-
env: {
|
|
2463
|
-
KHOTAN_API_URL: "<your-khotan-api-url>",
|
|
2464
|
-
KHOTAN_API_KEY: "<your-khotan-api-key>"
|
|
2465
|
-
}
|
|
2955
|
+
args: ["mcp", "serve"]
|
|
2466
2956
|
};
|
|
2467
2957
|
}
|
|
2468
2958
|
var GUIDE_BODY = `This workspace is wired to Khotan through the \`khotan\` CLI and its MCP server.
|
|
2469
2959
|
Use them to inspect and configure apps, pipelines, databases, files, folders, and
|
|
2470
2960
|
context documents through the Khotan \`/api/v1\` surface.
|
|
2471
2961
|
|
|
2962
|
+
## Org isolation (never cross customers)
|
|
2963
|
+
|
|
2964
|
+
Khotan credentials are machine-global unless scoped per repo. Pin this repo to
|
|
2965
|
+
its organization with \`KHOTAN_ORG_ID\` in \`env.khotan.local\`: the CLI and MCP
|
|
2966
|
+
server load that file and assert \`whoami.organizationId === KHOTAN_ORG_ID\`
|
|
2967
|
+
before trusting any output, failing closed on mismatch. You can also pass
|
|
2968
|
+
\`--assert-org <id>\` on any command.
|
|
2969
|
+
|
|
2970
|
+
If \`apps list\` / \`context list\` returns another customer's data (foreign product
|
|
2971
|
+
names, wrong org slug), **stop** — credentials are wrong. Do not infer
|
|
2972
|
+
deployment or env from CLI output until the org assertion passes.
|
|
2973
|
+
|
|
2472
2974
|
## Authentication (do this once — never commit secrets)
|
|
2473
2975
|
|
|
2474
|
-
|
|
2475
|
-
|
|
2976
|
+
Repo credentials live in \`env.khotan.local\` (\`KHOTAN_API_URL\`, \`KHOTAN_API_KEY\`,
|
|
2977
|
+
\`KHOTAN_ORG_ID\`), which the CLI auto-loads. Alternatively:
|
|
2476
2978
|
|
|
2477
|
-
- Profile
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2979
|
+
- Profile: \`khotan auth set-key --api-url <url> --api-key <key>\` stores the key in
|
|
2980
|
+
an owner-only file outside the repository (use a per-customer profile name, not
|
|
2981
|
+
\`default\`).
|
|
2982
|
+
- Environment: export \`KHOTAN_API_URL\`, \`KHOTAN_API_KEY\`, and \`KHOTAN_ORG_ID\`.
|
|
2481
2983
|
|
|
2482
2984
|
Never put an API key in a file committed to the repository. The generated MCP
|
|
2483
|
-
config
|
|
2985
|
+
config carries no secrets — the server resolves credentials at startup.
|
|
2986
|
+
|
|
2987
|
+
## Workspace layout
|
|
2988
|
+
|
|
2989
|
+
A Khotan workspace is an **organization mono-root repo**: one GitHub repo whose
|
|
2990
|
+
top-level subdirectories are each an independent deployable (an app or a
|
|
2991
|
+
pipeline). The repo root holds shared agent skills and instruction files;
|
|
2992
|
+
per-app guidance lives in each subdirectory's own \`AGENTS.md\` / \`CLAUDE.md\`.
|
|
2993
|
+
|
|
2994
|
+
- Run \`khotan status\` to see which subdirectory maps to which app/pipeline and
|
|
2995
|
+
database, and which repo it deploys from. \`khotan status --write\` refreshes a
|
|
2996
|
+
committable \`WORKSPACE.md\` with that map.
|
|
2997
|
+
- Each app lives in its own subdirectory — **do not run app scaffolders (e.g.
|
|
2998
|
+
\`khotan-data init\`) at the repo root.** \`cd\` into the app's subdirectory first.
|
|
2484
2999
|
|
|
2485
3000
|
## CLI surface
|
|
2486
3001
|
|
|
3002
|
+
- \`khotan status\` — the workspace topology (apps, pipelines, databases) at a glance
|
|
2487
3003
|
- \`khotan apps list|get|create|delete|redeploy\` and \`khotan apps env …\`
|
|
2488
3004
|
- \`khotan pipelines …\`, \`khotan databases …\`
|
|
2489
3005
|
- \`khotan files list|upload|download|…\`, \`khotan folders …\`
|
|
@@ -2503,7 +3019,9 @@ surface.
|
|
|
2503
3019
|
## MCP
|
|
2504
3020
|
|
|
2505
3021
|
\`khotan mcp serve\` exposes the same operations as MCP tools and durable reads as
|
|
2506
|
-
MCP resources,
|
|
3022
|
+
MCP resources, scoping credentials from \`env.khotan.local\` (or your stored
|
|
3023
|
+
profile) and asserting the org at startup. Restart the MCP server after changing
|
|
3024
|
+
\`env.khotan.local\`.
|
|
2507
3025
|
`;
|
|
2508
3026
|
var CURSOR_RULES = `---
|
|
2509
3027
|
description: Use the Khotan CLI and MCP tools to configure and operate this workspace's Khotan resources
|
|
@@ -2519,31 +3037,59 @@ ${GUIDE_BODY}`;
|
|
|
2519
3037
|
function toRelative(cwd, absolutePath) {
|
|
2520
3038
|
return relative(cwd, absolutePath) || absolutePath;
|
|
2521
3039
|
}
|
|
3040
|
+
function detectMonoRoot(cwd) {
|
|
3041
|
+
if (hasWorkspacesManifest(join3(cwd, "package.json")))
|
|
3042
|
+
return true;
|
|
3043
|
+
if (existsSync2(join3(cwd, "turbo.json")))
|
|
3044
|
+
return true;
|
|
3045
|
+
if (existsSync2(join3(cwd, "WORKSPACE.md")))
|
|
3046
|
+
return true;
|
|
3047
|
+
if (existsSync2(join3(cwd, "package.json")))
|
|
3048
|
+
return false;
|
|
3049
|
+
try {
|
|
3050
|
+
return readdirSync(cwd, { withFileTypes: true }).some((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules" && existsSync2(join3(cwd, entry.name, "package.json")));
|
|
3051
|
+
} catch {
|
|
3052
|
+
return false;
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
function hasWorkspacesManifest(packageJsonPath) {
|
|
3056
|
+
if (!existsSync2(packageJsonPath))
|
|
3057
|
+
return false;
|
|
3058
|
+
try {
|
|
3059
|
+
const parsed = JSON.parse(readFileSync3(packageJsonPath, "utf8"));
|
|
3060
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
3061
|
+
return false;
|
|
3062
|
+
const workspaces = parsed.workspaces;
|
|
3063
|
+
return Array.isArray(workspaces) && workspaces.length > 0;
|
|
3064
|
+
} catch {
|
|
3065
|
+
return false;
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
2522
3068
|
function writeTextAsset(ctx, absolutePath, content) {
|
|
2523
3069
|
const path = toRelative(ctx.cwd, absolutePath);
|
|
2524
|
-
const existed =
|
|
3070
|
+
const existed = existsSync2(absolutePath);
|
|
2525
3071
|
if (existed && !ctx.force) {
|
|
2526
3072
|
ctx.results.push({ path, action: "skipped" });
|
|
2527
3073
|
return;
|
|
2528
3074
|
}
|
|
2529
|
-
mkdirSync2(
|
|
3075
|
+
mkdirSync2(dirname3(absolutePath), { recursive: true });
|
|
2530
3076
|
writeFileSync2(absolutePath, content);
|
|
2531
3077
|
ctx.results.push({ path, action: existed ? "forced" : "created" });
|
|
2532
3078
|
}
|
|
2533
3079
|
function writeManagedAsset(ctx, absolutePath, content) {
|
|
2534
3080
|
const path = toRelative(ctx.cwd, absolutePath);
|
|
2535
|
-
const existed =
|
|
2536
|
-
mkdirSync2(
|
|
3081
|
+
const existed = existsSync2(absolutePath);
|
|
3082
|
+
mkdirSync2(dirname3(absolutePath), { recursive: true });
|
|
2537
3083
|
writeFileSync2(absolutePath, content);
|
|
2538
3084
|
ctx.results.push({ path, action: existed ? "updated" : "created" });
|
|
2539
3085
|
}
|
|
2540
3086
|
function writeMcpConfig(ctx, absolutePath) {
|
|
2541
3087
|
const path = toRelative(ctx.cwd, absolutePath);
|
|
2542
3088
|
let config = {};
|
|
2543
|
-
const existed =
|
|
3089
|
+
const existed = existsSync2(absolutePath);
|
|
2544
3090
|
if (existed) {
|
|
2545
3091
|
try {
|
|
2546
|
-
config = JSON.parse(
|
|
3092
|
+
config = JSON.parse(readFileSync3(absolutePath, "utf8"));
|
|
2547
3093
|
} catch {
|
|
2548
3094
|
ctx.results.push({ path, action: "skipped" });
|
|
2549
3095
|
return;
|
|
@@ -2559,7 +3105,7 @@ function writeMcpConfig(ctx, absolutePath) {
|
|
|
2559
3105
|
return;
|
|
2560
3106
|
}
|
|
2561
3107
|
servers[MCP_SERVER_KEY] = mcpServerEntry();
|
|
2562
|
-
mkdirSync2(
|
|
3108
|
+
mkdirSync2(dirname3(absolutePath), { recursive: true });
|
|
2563
3109
|
writeFileSync2(absolutePath, `${JSON.stringify(config, null, 2)}
|
|
2564
3110
|
`);
|
|
2565
3111
|
ctx.results.push({
|
|
@@ -2571,19 +3117,20 @@ function runInit(options) {
|
|
|
2571
3117
|
const { io, cwd, client, force, json } = options;
|
|
2572
3118
|
const ctx = { cwd, force, results: [] };
|
|
2573
3119
|
if (client === "cursor") {
|
|
2574
|
-
writeMcpConfig(ctx,
|
|
2575
|
-
writeTextAsset(ctx,
|
|
3120
|
+
writeMcpConfig(ctx, join3(cwd, ".cursor", "mcp.json"));
|
|
3121
|
+
writeTextAsset(ctx, join3(cwd, ".cursor", "rules", "khotan.mdc"), CURSOR_RULES);
|
|
2576
3122
|
} else {
|
|
2577
|
-
writeMcpConfig(ctx,
|
|
2578
|
-
writeTextAsset(ctx,
|
|
3123
|
+
writeMcpConfig(ctx, join3(cwd, "mcp.json"));
|
|
3124
|
+
writeTextAsset(ctx, join3(cwd, "khotan-agents.md"), GENERIC_GUIDE);
|
|
2579
3125
|
}
|
|
2580
3126
|
for (const skill of generateSkills()) {
|
|
2581
3127
|
for (const base of SKILL_BASES) {
|
|
2582
|
-
writeManagedAsset(ctx,
|
|
3128
|
+
writeManagedAsset(ctx, join3(cwd, base, "skills", skill.name, "SKILL.md"), skill.content);
|
|
2583
3129
|
}
|
|
2584
3130
|
}
|
|
3131
|
+
const monoRoot = detectMonoRoot(cwd);
|
|
2585
3132
|
if (json) {
|
|
2586
|
-
io.out(`${JSON.stringify({ client, actions: ctx.results }, null, 2)}
|
|
3133
|
+
io.out(`${JSON.stringify({ client, monoRoot, actions: ctx.results }, null, 2)}
|
|
2587
3134
|
`);
|
|
2588
3135
|
return 0;
|
|
2589
3136
|
}
|
|
@@ -2592,12 +3139,322 @@ function runInit(options) {
|
|
|
2592
3139
|
errLine(io, ` ${result.action.padEnd(8)} ${result.path}`);
|
|
2593
3140
|
}
|
|
2594
3141
|
errLine(io);
|
|
3142
|
+
if (monoRoot) {
|
|
3143
|
+
errLine(io, "Detected a Khotan mono-root workspace (multiple apps under one repo).");
|
|
3144
|
+
errLine(io, "Each app lives in its own subdirectory — do NOT run app scaffolders");
|
|
3145
|
+
errLine(io, "(e.g. `khotan-data init`) at this root. `cd` into an app subdirectory");
|
|
3146
|
+
errLine(io, "first. Run `khotan status` to see the layout.");
|
|
3147
|
+
errLine(io);
|
|
3148
|
+
}
|
|
2595
3149
|
errLine(io, "Next steps:");
|
|
2596
3150
|
errLine(io, " 1. Authenticate: khotan auth set-key --api-url <url> --api-key <key>");
|
|
2597
3151
|
errLine(io, " 2. Open this workspace in your agent and ask it to configure Khotan.");
|
|
2598
3152
|
return 0;
|
|
2599
3153
|
}
|
|
2600
3154
|
|
|
3155
|
+
// src/cli/commands/status.ts
|
|
3156
|
+
init_io();
|
|
3157
|
+
import { writeFileSync as writeFileSync3 } from "node:fs";
|
|
3158
|
+
import { join as join4, relative as relative2 } from "node:path";
|
|
3159
|
+
function str(value) {
|
|
3160
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
3161
|
+
}
|
|
3162
|
+
function record(value) {
|
|
3163
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
3164
|
+
}
|
|
3165
|
+
function listOf(result, key) {
|
|
3166
|
+
const container = record(result);
|
|
3167
|
+
const candidate = container && Array.isArray(container[key]) && container[key] || Array.isArray(result) && result || container && soleArrayProperty(container) || [];
|
|
3168
|
+
return candidate.map(record).filter((r) => r !== null);
|
|
3169
|
+
}
|
|
3170
|
+
function soleArrayProperty(container) {
|
|
3171
|
+
const arrays = Object.keys(container).filter((k) => Array.isArray(container[k]));
|
|
3172
|
+
return arrays.length === 1 ? container[arrays[0]] : null;
|
|
3173
|
+
}
|
|
3174
|
+
function toResourceRow(kind, raw) {
|
|
3175
|
+
const repo = record(raw.repo);
|
|
3176
|
+
return {
|
|
3177
|
+
kind,
|
|
3178
|
+
name: str(raw.displayName) ?? str(raw.slug) ?? "(unnamed)",
|
|
3179
|
+
slug: str(raw.slug) ?? "",
|
|
3180
|
+
status: str(raw.status) ?? "unknown",
|
|
3181
|
+
subdirectory: repo ? str(repo.subdirectory) : null,
|
|
3182
|
+
repo: repo ? str(repo.fullName) : null,
|
|
3183
|
+
branch: repo ? str(repo.defaultBranch) : null,
|
|
3184
|
+
hostname: str(raw.liveUrl) ?? str(raw.primaryHostname) ?? null
|
|
3185
|
+
};
|
|
3186
|
+
}
|
|
3187
|
+
function toDatabaseRow(raw) {
|
|
3188
|
+
return {
|
|
3189
|
+
name: str(raw.displayName) ?? "(unnamed)",
|
|
3190
|
+
status: str(raw.status) ?? "unknown",
|
|
3191
|
+
environment: str(raw.environment),
|
|
3192
|
+
region: str(raw.regionId),
|
|
3193
|
+
host: str(raw.endpointHost)
|
|
3194
|
+
};
|
|
3195
|
+
}
|
|
3196
|
+
function toRedisRow(raw) {
|
|
3197
|
+
return {
|
|
3198
|
+
name: str(raw.displayName) ?? "(unnamed)",
|
|
3199
|
+
status: str(raw.status) ?? "unknown",
|
|
3200
|
+
environment: null,
|
|
3201
|
+
region: str(raw.primaryRegion),
|
|
3202
|
+
host: str(raw.endpointHost)
|
|
3203
|
+
};
|
|
3204
|
+
}
|
|
3205
|
+
async function readList(session, capabilityId, domain, errors, map, responseKey = domain) {
|
|
3206
|
+
try {
|
|
3207
|
+
const result = await session.client.execute(capabilityId);
|
|
3208
|
+
return map(listOf(result, responseKey));
|
|
3209
|
+
} catch (error) {
|
|
3210
|
+
errors.push({
|
|
3211
|
+
domain,
|
|
3212
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3213
|
+
});
|
|
3214
|
+
return [];
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
async function loadTopology(session) {
|
|
3218
|
+
const errors = [];
|
|
3219
|
+
const apps = await readList(session, "apps.list", "apps", errors, (rows) => rows.map((r) => toResourceRow("app", r)));
|
|
3220
|
+
const pipelines = await readList(session, "pipelines.list", "pipelines", errors, (rows) => rows.map((r) => toResourceRow("pipeline", r)));
|
|
3221
|
+
const databases = await readList(session, "databases.list", "databases", errors, (rows) => rows.map(toDatabaseRow));
|
|
3222
|
+
const redisDatabases = await readList(session, "redis.list", "redis", errors, (rows) => rows.map(toRedisRow), "databases");
|
|
3223
|
+
const repoBearer = [...apps, ...pipelines].find((r) => r.repo);
|
|
3224
|
+
return {
|
|
3225
|
+
repo: repoBearer?.repo ?? null,
|
|
3226
|
+
defaultBranch: repoBearer?.branch ?? null,
|
|
3227
|
+
apps,
|
|
3228
|
+
pipelines,
|
|
3229
|
+
databases,
|
|
3230
|
+
redisDatabases,
|
|
3231
|
+
errors
|
|
3232
|
+
};
|
|
3233
|
+
}
|
|
3234
|
+
var EM_DASH = "—";
|
|
3235
|
+
function table(rows) {
|
|
3236
|
+
if (rows.length === 0) {
|
|
3237
|
+
return [" (none)"];
|
|
3238
|
+
}
|
|
3239
|
+
const columns = Object.keys(rows[0]);
|
|
3240
|
+
const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => (row[column] ?? "").length)));
|
|
3241
|
+
const format = (cells) => ` ${cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(" ").trimEnd()}`;
|
|
3242
|
+
return [
|
|
3243
|
+
format(columns),
|
|
3244
|
+
format(widths.map((width) => "-".repeat(width))),
|
|
3245
|
+
...rows.map((row) => format(columns.map((column) => row[column] ?? "")))
|
|
3246
|
+
];
|
|
3247
|
+
}
|
|
3248
|
+
function resourceTable(rows) {
|
|
3249
|
+
return rows.map((row) => ({
|
|
3250
|
+
subdir: row.subdirectory ?? EM_DASH,
|
|
3251
|
+
name: row.name,
|
|
3252
|
+
status: row.status,
|
|
3253
|
+
hostname: row.hostname ?? EM_DASH
|
|
3254
|
+
}));
|
|
3255
|
+
}
|
|
3256
|
+
function deployables(topology) {
|
|
3257
|
+
return [...topology.apps, ...topology.pipelines];
|
|
3258
|
+
}
|
|
3259
|
+
function deployableLayout(topology) {
|
|
3260
|
+
if (topology.errors.some((error) => error.domain === "apps" || error.domain === "pipelines")) {
|
|
3261
|
+
return "partial";
|
|
3262
|
+
}
|
|
3263
|
+
const rows = deployables(topology);
|
|
3264
|
+
if (rows.length === 0)
|
|
3265
|
+
return "empty";
|
|
3266
|
+
const withSubdir = rows.filter((row) => row.subdirectory).length;
|
|
3267
|
+
if (withSubdir === rows.length)
|
|
3268
|
+
return "subdirectory";
|
|
3269
|
+
if (withSubdir === 0)
|
|
3270
|
+
return "whole-repo";
|
|
3271
|
+
return "mixed";
|
|
3272
|
+
}
|
|
3273
|
+
function renderLayoutIntro(io, topology) {
|
|
3274
|
+
const layout = deployableLayout(topology);
|
|
3275
|
+
if (layout === "subdirectory") {
|
|
3276
|
+
outLine(io, "Every app and pipeline below lives as a subdirectory of one shared");
|
|
3277
|
+
outLine(io, "organization mono-root repo — that repo is the deploy source. Each");
|
|
3278
|
+
outLine(io, "subdirectory is an independent deployable. Databases are separate");
|
|
3279
|
+
outLine(io, "resources, linked to apps via env vars (`khotan apps env list`), not");
|
|
3280
|
+
outLine(io, "by directory.");
|
|
3281
|
+
return;
|
|
3282
|
+
}
|
|
3283
|
+
if (layout === "mixed") {
|
|
3284
|
+
outLine(io, "Some deployables below live in subdirectories of the deploy repo;");
|
|
3285
|
+
outLine(io, "rows marked with — use the repo root or have no subdirectory metadata.");
|
|
3286
|
+
} else if (layout === "whole-repo") {
|
|
3287
|
+
outLine(io, "Deployables below use the deploy repo root or have no subdirectory");
|
|
3288
|
+
outLine(io, "metadata, rather than an organization mono-root subdirectory layout.");
|
|
3289
|
+
} else if (layout === "partial") {
|
|
3290
|
+
outLine(io, "Some app or pipeline data could not be read, so the deployable");
|
|
3291
|
+
outLine(io, "layout below is incomplete. Treat missing deployables as unknown.");
|
|
3292
|
+
} else {
|
|
3293
|
+
outLine(io, "No app or pipeline deployables were returned yet.");
|
|
3294
|
+
}
|
|
3295
|
+
outLine(io, "Databases are separate resources, linked to apps via env vars");
|
|
3296
|
+
outLine(io, "(`khotan apps env list`), not by directory.");
|
|
3297
|
+
}
|
|
3298
|
+
function renderHuman(io, topology) {
|
|
3299
|
+
outLine(io, "Workspace topology");
|
|
3300
|
+
outLine(io);
|
|
3301
|
+
renderLayoutIntro(io, topology);
|
|
3302
|
+
outLine(io);
|
|
3303
|
+
outLine(io, `Deploy repo: ${topology.repo ?? "(none provisioned yet)"}` + (topology.defaultBranch ? ` (branch: ${topology.defaultBranch})` : ""));
|
|
3304
|
+
outLine(io);
|
|
3305
|
+
outLine(io, `Apps (${topology.apps.length})`);
|
|
3306
|
+
for (const line of table(resourceTable(topology.apps)))
|
|
3307
|
+
outLine(io, line);
|
|
3308
|
+
outLine(io);
|
|
3309
|
+
outLine(io, `Pipelines (${topology.pipelines.length})`);
|
|
3310
|
+
for (const line of table(resourceTable(topology.pipelines)))
|
|
3311
|
+
outLine(io, line);
|
|
3312
|
+
outLine(io);
|
|
3313
|
+
outLine(io, `Databases (${topology.databases.length})`);
|
|
3314
|
+
for (const line of table(topology.databases.map((db) => ({
|
|
3315
|
+
name: db.name,
|
|
3316
|
+
status: db.status,
|
|
3317
|
+
region: db.region ?? EM_DASH,
|
|
3318
|
+
host: db.host ?? EM_DASH
|
|
3319
|
+
})))) {
|
|
3320
|
+
outLine(io, line);
|
|
3321
|
+
}
|
|
3322
|
+
outLine(io);
|
|
3323
|
+
outLine(io, `Redis (KV) (${topology.redisDatabases.length})`);
|
|
3324
|
+
for (const line of table(topology.redisDatabases.map((db) => ({
|
|
3325
|
+
name: db.name,
|
|
3326
|
+
status: db.status,
|
|
3327
|
+
region: db.region ?? EM_DASH,
|
|
3328
|
+
host: db.host ?? EM_DASH
|
|
3329
|
+
})))) {
|
|
3330
|
+
outLine(io, line);
|
|
3331
|
+
}
|
|
3332
|
+
for (const failure of topology.errors) {
|
|
3333
|
+
errLine(io, `! could not read ${failure.domain}: ${failure.message}`);
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
function markdownTable(headers, rows) {
|
|
3337
|
+
const lines = [
|
|
3338
|
+
`| ${headers.join(" | ")} |`,
|
|
3339
|
+
`| ${headers.map(() => "---").join(" | ")} |`,
|
|
3340
|
+
...rows.map((row) => `| ${row.join(" | ")} |`)
|
|
3341
|
+
];
|
|
3342
|
+
return lines.join(`
|
|
3343
|
+
`);
|
|
3344
|
+
}
|
|
3345
|
+
function mdCell(value) {
|
|
3346
|
+
return value ? value : EM_DASH;
|
|
3347
|
+
}
|
|
3348
|
+
function workspaceIntro(topology) {
|
|
3349
|
+
const layout = deployableLayout(topology);
|
|
3350
|
+
if (layout === "subdirectory") {
|
|
3351
|
+
return [
|
|
3352
|
+
"This directory is one **organization mono-root repo**: a single GitHub repo",
|
|
3353
|
+
"whose top-level subdirectories are each an independent Khotan deployable (an",
|
|
3354
|
+
"app or a pipeline). Cloning the repo (or a sparse subdirectory checkout) gives",
|
|
3355
|
+
"you the code; each subdirectory deploys on its own from this same repo.",
|
|
3356
|
+
"",
|
|
3357
|
+
"**Do not run app scaffolders (e.g. `khotan-data init`) at this root.** Each",
|
|
3358
|
+
"app already lives in its own subdirectory — `cd` into it first."
|
|
3359
|
+
];
|
|
3360
|
+
}
|
|
3361
|
+
if (layout === "mixed") {
|
|
3362
|
+
return [
|
|
3363
|
+
"This file records Khotan deployables for this repository. Some deployables",
|
|
3364
|
+
"live in subdirectories; deployables marked with — use the repo root or have",
|
|
3365
|
+
"no subdirectory metadata.",
|
|
3366
|
+
"",
|
|
3367
|
+
"For deployables with a subdirectory, `cd` into that directory before running",
|
|
3368
|
+
"app scaffolders. For rows marked —, verify the resource layout before",
|
|
3369
|
+
"scaffolding."
|
|
3370
|
+
];
|
|
3371
|
+
}
|
|
3372
|
+
if (layout === "whole-repo") {
|
|
3373
|
+
return [
|
|
3374
|
+
"This file records Khotan deployables for this repository. The returned",
|
|
3375
|
+
"deployables use the repo root or have no subdirectory metadata, so this is",
|
|
3376
|
+
"not described as an organization mono-root subdirectory layout."
|
|
3377
|
+
];
|
|
3378
|
+
}
|
|
3379
|
+
if (layout === "partial") {
|
|
3380
|
+
return [
|
|
3381
|
+
"This file records Khotan deployables for this repository. Some app or",
|
|
3382
|
+
"pipeline data could not be read, so the deployable layout is incomplete.",
|
|
3383
|
+
"Use the Read Errors section before deciding where to run scaffolders."
|
|
3384
|
+
];
|
|
3385
|
+
}
|
|
3386
|
+
return [
|
|
3387
|
+
"This file records Khotan deployables for this repository. No apps or pipelines",
|
|
3388
|
+
"were returned yet; re-run `khotan status --write` after provisioning resources",
|
|
3389
|
+
"to capture their deploy mapping."
|
|
3390
|
+
];
|
|
3391
|
+
}
|
|
3392
|
+
function errorsSection(errors) {
|
|
3393
|
+
if (errors.length === 0)
|
|
3394
|
+
return [];
|
|
3395
|
+
return [
|
|
3396
|
+
"",
|
|
3397
|
+
"## Read Errors",
|
|
3398
|
+
"",
|
|
3399
|
+
"Some live data could not be read while generating this file. Treat affected",
|
|
3400
|
+
"empty sections as unknown, not as confirmed empty.",
|
|
3401
|
+
"",
|
|
3402
|
+
markdownTable(["Domain", "Error"], errors.map((error) => [error.domain, error.message]))
|
|
3403
|
+
];
|
|
3404
|
+
}
|
|
3405
|
+
function buildWorkspaceManifest(topology) {
|
|
3406
|
+
const rows = deployables(topology);
|
|
3407
|
+
const sections = [];
|
|
3408
|
+
sections.push("# Workspace", "", "> Generated by `khotan status --write`. Re-run to refresh; safe to commit.", "", ...workspaceIntro(topology), "", `- **Deploy repo:** ${topology.repo ? `\`${topology.repo}\`` : "_(none provisioned yet)_"}` + (topology.defaultBranch ? ` (branch \`${topology.defaultBranch}\`)` : ""));
|
|
3409
|
+
sections.push("", "## Deployables", "", rows.length === 0 ? "_No apps or pipelines provisioned yet._" : markdownTable(["Subdirectory", "Resource", "Type", "Status", "Live URL"], rows.map((row) => [
|
|
3410
|
+
row.subdirectory ? `\`${row.subdirectory}\`` : EM_DASH,
|
|
3411
|
+
row.name,
|
|
3412
|
+
row.kind,
|
|
3413
|
+
row.status,
|
|
3414
|
+
row.hostname ? `https://${row.hostname.replace(/^https?:\/\//, "")}` : EM_DASH
|
|
3415
|
+
])));
|
|
3416
|
+
sections.push("", "## Databases", "", "Databases are separate resources. An app connects to one through environment", "variables (`khotan apps env list <appId>`), not by directory.", "", topology.databases.length === 0 ? "_No databases provisioned yet._" : markdownTable(["Name", "Status", "Environment", "Region", "Host"], topology.databases.map((db) => [
|
|
3417
|
+
db.name,
|
|
3418
|
+
db.status,
|
|
3419
|
+
mdCell(db.environment),
|
|
3420
|
+
mdCell(db.region),
|
|
3421
|
+
mdCell(db.host)
|
|
3422
|
+
])));
|
|
3423
|
+
sections.push("", "## Redis (KV)", "", "Redis (KV) databases are separate resources. An app connects to one through", "environment variables (`khotan apps env list <appId>`), not by directory.", "", topology.redisDatabases.length === 0 ? "_No Redis (KV) databases provisioned yet._" : markdownTable(["Name", "Status", "Region", "Host"], topology.redisDatabases.map((db) => [
|
|
3424
|
+
db.name,
|
|
3425
|
+
db.status,
|
|
3426
|
+
mdCell(db.region),
|
|
3427
|
+
mdCell(db.host)
|
|
3428
|
+
])));
|
|
3429
|
+
sections.push(...errorsSection(topology.errors));
|
|
3430
|
+
sections.push("", "## Working here", "", "- `khotan status` — reprint this topology (`--json` for machine output).", "- `khotan status --write` — regenerate this `WORKSPACE.md` from live data.", "- `khotan apps list` · `khotan pipelines list` · `khotan databases list` · `khotan redis list` — per-domain detail.", "");
|
|
3431
|
+
return sections.join(`
|
|
3432
|
+
`);
|
|
3433
|
+
}
|
|
3434
|
+
async function runStatus(options) {
|
|
3435
|
+
const { session, io, json, writeDir } = options;
|
|
3436
|
+
const topology = await loadTopology(session);
|
|
3437
|
+
if (writeDir) {
|
|
3438
|
+
const target = join4(writeDir, "WORKSPACE.md");
|
|
3439
|
+
writeFileSync3(target, buildWorkspaceManifest(topology));
|
|
3440
|
+
const shown = relative2(process.cwd(), target) || target;
|
|
3441
|
+
if (json) {
|
|
3442
|
+
outLine(io, JSON.stringify({ wrote: shown, topology }, null, 2));
|
|
3443
|
+
} else {
|
|
3444
|
+
renderHuman(io, topology);
|
|
3445
|
+
errLine(io);
|
|
3446
|
+
errLine(io, `Wrote ${shown}`);
|
|
3447
|
+
}
|
|
3448
|
+
return 0;
|
|
3449
|
+
}
|
|
3450
|
+
if (json) {
|
|
3451
|
+
outLine(io, JSON.stringify(topology, null, 2));
|
|
3452
|
+
return 0;
|
|
3453
|
+
}
|
|
3454
|
+
renderHuman(io, topology);
|
|
3455
|
+
return 0;
|
|
3456
|
+
}
|
|
3457
|
+
|
|
2601
3458
|
// src/cli/confirm.ts
|
|
2602
3459
|
init_src();
|
|
2603
3460
|
init_io();
|
|
@@ -2653,13 +3510,14 @@ async function runCapability(match, args, options) {
|
|
|
2653
3510
|
printCommandHelp(options.io, capability);
|
|
2654
3511
|
return 0;
|
|
2655
3512
|
}
|
|
2656
|
-
const session =
|
|
3513
|
+
const session = await resolveSessionAsserted({
|
|
2657
3514
|
env: options.env,
|
|
2658
3515
|
storeOptions: options.storeOptions,
|
|
2659
3516
|
profileName: options.profileName,
|
|
2660
3517
|
apiUrlOverride: options.apiUrlOverride,
|
|
2661
3518
|
apiKeyOverride: options.apiKeyOverride,
|
|
2662
|
-
fetch: options.fetch
|
|
3519
|
+
fetch: options.fetch,
|
|
3520
|
+
assertOrgOverride: options.assertOrgOverride
|
|
2663
3521
|
});
|
|
2664
3522
|
const input = buildCapabilityInput(capability, match.rest, args);
|
|
2665
3523
|
const json = options.jsonFlag || session.resolved.defaultOutput === "json";
|
|
@@ -2676,7 +3534,7 @@ async function runCapability(match, args, options) {
|
|
|
2676
3534
|
}
|
|
2677
3535
|
async function run(argv, options = {}) {
|
|
2678
3536
|
const io = options.io ?? createNodeIo();
|
|
2679
|
-
const env = options.env ?? io.env;
|
|
3537
|
+
const env = overlayRepoEnv(options.env ?? io.env, { cwd: process.cwd() }).env;
|
|
2680
3538
|
const args = parseArgs(argv);
|
|
2681
3539
|
const { positionals } = args;
|
|
2682
3540
|
const jsonFlag = flagBool(args, "json");
|
|
@@ -2685,6 +3543,7 @@ async function run(argv, options = {}) {
|
|
|
2685
3543
|
const profileName = flagValue(args, "profile");
|
|
2686
3544
|
const apiUrlOverride = flagValue(args, "api-url");
|
|
2687
3545
|
const apiKeyOverride = flagValue(args, "api-key");
|
|
3546
|
+
const assertOrgOverride = flagValue(args, "assert-org");
|
|
2688
3547
|
if (flagBool(args, "version")) {
|
|
2689
3548
|
const { KHOTAN_ADAPTER_VERSION: KHOTAN_ADAPTER_VERSION2 } = await Promise.resolve().then(() => (init_src(), exports_src));
|
|
2690
3549
|
io.out(`${KHOTAN_ADAPTER_VERSION2}
|
|
@@ -2708,7 +3567,12 @@ async function run(argv, options = {}) {
|
|
|
2708
3567
|
if (match2 && isOperationCapability(match2.capability)) {
|
|
2709
3568
|
printCommandHelp(io, match2.capability);
|
|
2710
3569
|
} else {
|
|
2711
|
-
|
|
3570
|
+
const group2 = groupCommands(topic);
|
|
3571
|
+
if (group2.length > 0) {
|
|
3572
|
+
printGroupHelp(io, topic, group2);
|
|
3573
|
+
} else {
|
|
3574
|
+
printTopLevelHelp(io);
|
|
3575
|
+
}
|
|
2712
3576
|
}
|
|
2713
3577
|
return 0;
|
|
2714
3578
|
}
|
|
@@ -2724,6 +3588,26 @@ async function run(argv, options = {}) {
|
|
|
2724
3588
|
const cwd = flagValue(args, "dir") ?? process.cwd();
|
|
2725
3589
|
return runInit({ io, cwd, client: clientRaw, force: flagBool(args, "force"), json: jsonFlag });
|
|
2726
3590
|
}
|
|
3591
|
+
if (first === "status") {
|
|
3592
|
+
if (helpFlag) {
|
|
3593
|
+
errLine(io, "Usage: khotan status [--write [path]] [--dir <path>] [--json]");
|
|
3594
|
+
return 0;
|
|
3595
|
+
}
|
|
3596
|
+
const session = await resolveSessionAsserted({
|
|
3597
|
+
env,
|
|
3598
|
+
storeOptions: options.storeOptions,
|
|
3599
|
+
profileName,
|
|
3600
|
+
apiUrlOverride,
|
|
3601
|
+
apiKeyOverride,
|
|
3602
|
+
fetch: options.fetch,
|
|
3603
|
+
assertOrgOverride
|
|
3604
|
+
});
|
|
3605
|
+
const json = jsonFlag || session.resolved.defaultOutput === "json";
|
|
3606
|
+
const writeValue = flagValue(args, "write");
|
|
3607
|
+
const write = flagBool(args, "write");
|
|
3608
|
+
const writeDir = write ? flagValue(args, "dir") ?? (writeValue && writeValue !== "true" ? writeValue : undefined) ?? process.cwd() : undefined;
|
|
3609
|
+
return await runStatus({ session, io, json, writeDir });
|
|
3610
|
+
}
|
|
2727
3611
|
if (first === "mcp" && positionals[1] === "serve") {
|
|
2728
3612
|
const launcher = options.startMcpServer ?? (async (deps) => {
|
|
2729
3613
|
const { serveMcp: serveMcp2 } = await Promise.resolve().then(() => (init_serve(), exports_serve));
|
|
@@ -2736,7 +3620,8 @@ async function run(argv, options = {}) {
|
|
|
2736
3620
|
fetch: options.fetch,
|
|
2737
3621
|
profileName,
|
|
2738
3622
|
apiUrlOverride,
|
|
2739
|
-
apiKeyOverride
|
|
3623
|
+
apiKeyOverride,
|
|
3624
|
+
assertOrgOverride
|
|
2740
3625
|
});
|
|
2741
3626
|
}
|
|
2742
3627
|
if (first === "login") {
|
|
@@ -2769,13 +3654,14 @@ async function run(argv, options = {}) {
|
|
|
2769
3654
|
errLine(io, positionals[1] === "upload" ? "Usage: khotan files upload <localPath> [--name <n>] [--folder-id <id>] [--folder-path <p>] [--content-type <t>]" : "Usage: khotan files download <fileId> --output <path>");
|
|
2770
3655
|
return 0;
|
|
2771
3656
|
}
|
|
2772
|
-
const session =
|
|
3657
|
+
const session = await resolveSessionAsserted({
|
|
2773
3658
|
env,
|
|
2774
3659
|
storeOptions: options.storeOptions,
|
|
2775
3660
|
profileName,
|
|
2776
3661
|
apiUrlOverride,
|
|
2777
3662
|
apiKeyOverride,
|
|
2778
|
-
fetch: options.fetch
|
|
3663
|
+
fetch: options.fetch,
|
|
3664
|
+
assertOrgOverride
|
|
2779
3665
|
});
|
|
2780
3666
|
const json = jsonFlag || session.resolved.defaultOutput === "json";
|
|
2781
3667
|
if (positionals[1] === "upload") {
|
|
@@ -2808,9 +3694,15 @@ async function run(argv, options = {}) {
|
|
|
2808
3694
|
yes,
|
|
2809
3695
|
profileName,
|
|
2810
3696
|
apiUrlOverride,
|
|
2811
|
-
apiKeyOverride
|
|
3697
|
+
apiKeyOverride,
|
|
3698
|
+
assertOrgOverride
|
|
2812
3699
|
});
|
|
2813
3700
|
}
|
|
3701
|
+
const group = groupCommands(positionals);
|
|
3702
|
+
if (group.length > 0) {
|
|
3703
|
+
printGroupHelp(io, positionals, group);
|
|
3704
|
+
return 0;
|
|
3705
|
+
}
|
|
2814
3706
|
errLine(io, `Unknown command "${positionals.join(" ")}". Run \`khotan help\` for usage.`);
|
|
2815
3707
|
return 1;
|
|
2816
3708
|
} catch (error) {
|