@mesh-tech/mesh-cli 0.38.1 → 0.40.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/dist/bin/{mesh.auth-preflight-AQJ5TQES.js → mesh.auth-preflight-AUFQ2FGD.js} +1 -1
- package/dist/bin/{mesh.chunk-I6NXLXGC.js → mesh.chunk-2ASXJ5DO.js} +235 -581
- package/dist/bin/mesh.chunk-2ASXJ5DO.js.map +6 -0
- package/dist/bin/{mesh.chunk-IU2IWH6O.js → mesh.chunk-6AIG5OJM.js} +3 -3
- package/dist/bin/{mesh.chunk-KN4BZOYH.js → mesh.chunk-6SSOVZOI.js} +1 -1
- package/dist/bin/{mesh.chunk-LMYPTJ2F.js → mesh.chunk-7HTKSUWR.js} +1900 -577
- package/dist/bin/mesh.chunk-7HTKSUWR.js.map +6 -0
- package/dist/bin/{mesh.chunk-2YRWJH5X.js → mesh.chunk-F44A3ACW.js} +4 -4
- package/dist/bin/{mesh.chunk-2YRWJH5X.js.map → mesh.chunk-F44A3ACW.js.map} +1 -1
- package/dist/bin/{mesh.chunk-BF2SH6XT.js → mesh.chunk-HJGMOEUG.js} +1 -1
- package/dist/bin/{mesh.chunk-TZAGJTKU.js → mesh.chunk-NSQJEX36.js} +1 -1
- package/dist/bin/{mesh.chunk-HPYZWPRM.js → mesh.chunk-PZVI7V3J.js} +1 -1
- package/dist/bin/mesh.chunk-TDCH4SFQ.js +419 -0
- package/dist/bin/mesh.chunk-TDCH4SFQ.js.map +6 -0
- package/dist/bin/mesh.chunk-YE2WIY3Y.js +473 -0
- package/dist/bin/mesh.chunk-YE2WIY3Y.js.map +6 -0
- package/dist/bin/{mesh.create-app-NGXACFAU.js → mesh.create-app-Z3RKSTBO.js} +3 -3
- package/dist/bin/{mesh.dev-token-server-YP2VBP2S.js → mesh.dev-token-server-XG26C6IQ.js} +2 -2
- package/dist/bin/mesh.generate-XSM4C6FQ.js +10 -0
- package/dist/bin/mesh.js +9 -8
- package/dist/bin/mesh.js.map +1 -1
- package/dist/bin/{mesh.login-7SIAKFXJ.js → mesh.login-3AWVGPCM.js} +2 -2
- package/dist/bin/{mesh.platform-setup-CJXE3BCP.js → mesh.platform-setup-2ZDMHRCI.js} +1 -1
- package/dist/bin/{mesh.program-BIDQBSSI.js → mesh.program-K5JQXEHX.js} +9 -8
- package/dist/bin/{mesh.registry-7P4MGXEK.js → mesh.registry-JLQYFUNN.js} +4 -4
- package/dist/bin/{mesh.registry-identity-JW4KO7N7.js → mesh.registry-identity-3AR27UGO.js} +3 -3
- package/dist/bin/mesh.registry-identity-3AR27UGO.js.map +6 -0
- package/dist/bin/mesh.review-command-LUVZQN2T.js +238 -0
- package/dist/bin/mesh.review-command-LUVZQN2T.js.map +6 -0
- package/dist/bin/{mesh.start-MPQFIEMH.js → mesh.start-KZ5L6SOJ.js} +2 -2
- package/dist/bin/{mesh.wizard-ZJE4S6PC.js → mesh.wizard-FCTT55KI.js} +7 -7
- package/dist/bin/mesh.workspace-membership-DI3YQGEK.js +65 -0
- package/dist/bin/mesh.workspace-membership-DI3YQGEK.js.map +6 -0
- package/dist/build-info.json +2 -2
- package/dist/src/commands/app-check.js +2 -2
- package/dist/src/commands/dev-doctor.d.ts +22 -0
- package/dist/src/commands/dev-doctor.js +149 -0
- package/dist/src/commands/dev-env-leaks.d.ts +24 -0
- package/dist/src/commands/dev-env-leaks.js +178 -0
- package/dist/src/commands/dev-launch.d.ts +17 -1
- package/dist/src/commands/dev-launch.js +130 -5
- package/dist/src/commands/dev-readiness.d.ts +49 -0
- package/dist/src/commands/dev-readiness.js +78 -0
- package/dist/src/commands/dev.d.ts +60 -0
- package/dist/src/commands/dev.js +574 -140
- package/dist/src/commands/hub/index.js +10 -7
- package/dist/src/commands/local/app-authz.d.ts +118 -0
- package/dist/src/commands/local/app-authz.js +295 -0
- package/dist/src/commands/local/auth-provision.d.ts +1 -0
- package/dist/src/commands/local/auth-provision.js +12 -1
- package/dist/src/commands/local/index.js +2 -0
- package/dist/src/commands/mcp/generate.d.ts +11 -0
- package/dist/src/commands/mcp/generate.js +78 -0
- package/dist/src/commands/mcp/index.d.ts +2 -0
- package/dist/src/commands/mcp/index.js +70 -0
- package/dist/src/commands/mcp/manifest-file.d.ts +5 -0
- package/dist/src/commands/mcp/manifest-file.js +24 -0
- package/dist/src/commands/mcp/review-command.d.ts +24 -0
- package/dist/src/commands/mcp/review-command.js +66 -0
- package/dist/src/commands/mcp/review.d.ts +41 -0
- package/dist/src/commands/mcp/review.js +140 -0
- package/dist/src/commands/mcp/workspace-membership.d.ts +7 -0
- package/dist/src/commands/mcp/workspace-membership.js +77 -0
- package/dist/src/commands/secrets/reindex.js +2 -7
- package/dist/src/commands/secrets/set.d.ts +22 -1
- package/dist/src/commands/secrets/set.js +31 -8
- package/dist/src/commands/temporal.js +51 -5
- package/dist/src/commands/temporal.js.map +2 -2
- package/dist/src/program.js +2 -0
- package/dist/src/utils/aws-auth.d.ts +4 -0
- package/dist/src/utils/aws-auth.js +7 -3
- package/dist/src/utils/dev-sessions-root.d.ts +3 -0
- package/dist/src/utils/dev-sessions-root.js +45 -0
- package/dist/src/utils/kubeconfig.js +2 -2
- package/dist/src/utils/tailscale.js +7 -1
- package/dist/src/utils/temporal-auth.js +2 -1
- package/dist/src/utils/workflow-fingerprint.js +23 -12
- package/fragments/base/index.ts.hbs +4 -1
- package/package.json +7 -5
- package/skills/mesh/SKILL.md +10 -6
- package/templates/api-auth/api/src/schema.ts.hbs +17 -0
- package/templates/api-auth/index.ts.hbs +1 -0
- package/templates/api-auth/package.json.hbs +3 -0
- package/templates/api-role-gating/api/src/schema.ts.hbs +10 -0
- package/templates/api-role-gating/index.ts.hbs +1 -0
- package/templates/external-service/index.ts.hbs +1 -0
- package/templates/workflow/index.ts.hbs +2 -0
- package/dist/bin/mesh.chunk-I6NXLXGC.js.map +0 -6
- package/dist/bin/mesh.chunk-LMYPTJ2F.js.map +0 -6
- /package/dist/bin/{mesh.auth-preflight-AQJ5TQES.js.map → mesh.auth-preflight-AUFQ2FGD.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-IU2IWH6O.js.map → mesh.chunk-6AIG5OJM.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-KN4BZOYH.js.map → mesh.chunk-6SSOVZOI.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-BF2SH6XT.js.map → mesh.chunk-HJGMOEUG.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-TZAGJTKU.js.map → mesh.chunk-NSQJEX36.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-HPYZWPRM.js.map → mesh.chunk-PZVI7V3J.js.map} +0 -0
- /package/dist/bin/{mesh.create-app-NGXACFAU.js.map → mesh.create-app-Z3RKSTBO.js.map} +0 -0
- /package/dist/bin/{mesh.dev-token-server-YP2VBP2S.js.map → mesh.dev-token-server-XG26C6IQ.js.map} +0 -0
- /package/dist/bin/{mesh.login-7SIAKFXJ.js.map → mesh.generate-XSM4C6FQ.js.map} +0 -0
- /package/dist/bin/{mesh.platform-setup-CJXE3BCP.js.map → mesh.login-3AWVGPCM.js.map} +0 -0
- /package/dist/bin/{mesh.program-BIDQBSSI.js.map → mesh.platform-setup-2ZDMHRCI.js.map} +0 -0
- /package/dist/bin/{mesh.registry-7P4MGXEK.js.map → mesh.program-K5JQXEHX.js.map} +0 -0
- /package/dist/bin/{mesh.registry-identity-JW4KO7N7.js.map → mesh.registry-JLQYFUNN.js.map} +0 -0
- /package/dist/bin/{mesh.start-MPQFIEMH.js.map → mesh.start-KZ5L6SOJ.js.map} +0 -0
- /package/dist/bin/{mesh.wizard-ZJE4S6PC.js.map → mesh.wizard-FCTT55KI.js.map} +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { logError, logInfo, logWarn } from "../../utils/log.js";
|
|
3
|
+
export function registerMcpCommands(program) {
|
|
4
|
+
const mcp = program
|
|
5
|
+
.command("mcp")
|
|
6
|
+
.description("Manifest-driven MCP Integrations (reviewed tool catalogs)");
|
|
7
|
+
mcp
|
|
8
|
+
.command("review <manifest>")
|
|
9
|
+
.description("Fetch a manifest's MCP server tools/list with the Integration's credential, diff it against the checked-in snapshot, and write the snapshot on confirmation")
|
|
10
|
+
.option("--yes", "Write without prompting")
|
|
11
|
+
.option("--dry-run", "Report the diff and write nothing")
|
|
12
|
+
.option("--stack <stack>", "Pulumi stack name (override detection)")
|
|
13
|
+
.option("-t, --tenant <tenant>", "Tenant to address (override detection)")
|
|
14
|
+
.option("--region <region>", "AWS region (default: us-east-2)")
|
|
15
|
+
.option("--secret <id>", "Secrets Manager secret holding the Integration credential (default: the deployed ExternalService's own secret)")
|
|
16
|
+
.action(async (manifest, opts) => {
|
|
17
|
+
const { runMcpReviewCommand } = await import("./review-command.js");
|
|
18
|
+
try {
|
|
19
|
+
await runMcpReviewCommand(manifest, opts);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
mcp
|
|
27
|
+
.command("generate <manifest>")
|
|
28
|
+
.description("Write the worker package mesh.apps.McpIntegration deploys (package.json, main.ts, openapi.json) from a manifest and its reviewed snapshot; commit it with the lockfile")
|
|
29
|
+
.option("--out <dir>", "Package directory (default: <manifest dir>/.mesh-mcp/<name>)")
|
|
30
|
+
.option("--refresh-versions", "Re-resolve every dependency version instead of keeping the committed ones")
|
|
31
|
+
.action(async (manifest, opts) => {
|
|
32
|
+
const { generateMcpServeDir } = await import("./generate.js");
|
|
33
|
+
const { workspaceMembership } = await import("./workspace-membership.js");
|
|
34
|
+
try {
|
|
35
|
+
const { serveDir, written } = generateMcpServeDir(manifest, {
|
|
36
|
+
...(opts.out ? { out: opts.out } : {}),
|
|
37
|
+
...(opts.refreshVersions ? { refreshVersions: true } : {}),
|
|
38
|
+
});
|
|
39
|
+
if (written.length === 0) {
|
|
40
|
+
logInfo(`${serveDir} is current.`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
logInfo(`Wrote ${written.join(", ")} in ${serveDir}.`);
|
|
44
|
+
if (written.includes("package.json")) {
|
|
45
|
+
logInfo("Run `pnpm install` so the lockfile records it, then commit both.");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const membership = workspaceMembership(serveDir);
|
|
49
|
+
if (!membership.workspaceFile) {
|
|
50
|
+
logWarn(`No pnpm-workspace.yaml above ${serveDir}: the worker image installs this package from a pnpm workspace, so it must be in one.`);
|
|
51
|
+
}
|
|
52
|
+
else if (!membership.member && membership.suggestedGlob) {
|
|
53
|
+
const { ensureWorkspaceGlobs } = await import("../create-app.js");
|
|
54
|
+
const added = ensureWorkspaceGlobs(path.dirname(membership.workspaceFile), [
|
|
55
|
+
membership.suggestedGlob,
|
|
56
|
+
]);
|
|
57
|
+
if (added.length > 0) {
|
|
58
|
+
logInfo(`${membership.workspaceFile}: added ${added.join(", ")} so \`pnpm install\` records the package.`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
logWarn(`${serveDir} is not a member of the workspace in ${membership.workspaceFile}, so \`pnpm install\` will not record it. Add "${membership.suggestedGlob}" to its packages.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { parseMcpManifest, resolveMcpManifest, } from "@mesh-tech/api-registry/mcp-manifest";
|
|
4
|
+
function readJson(file) {
|
|
5
|
+
let text;
|
|
6
|
+
try {
|
|
7
|
+
text = readFileSync(file, "utf8");
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
throw new Error(`MCP manifest: cannot read ${file} (${error instanceof Error ? error.message : String(error)})`);
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(text);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
throw new Error(`MCP manifest: ${file} is not valid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function readMcpManifestFile(manifestPath) {
|
|
20
|
+
const absolute = path.resolve(manifestPath);
|
|
21
|
+
const manifest = parseMcpManifest(readJson(absolute));
|
|
22
|
+
const toolsPath = path.resolve(path.dirname(absolute), manifest.tools);
|
|
23
|
+
return { ...resolveMcpManifest(manifest, readJson(toolsPath), { toolsPath }), toolsPath };
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { SSMClient } from "@aws-sdk/client-ssm";
|
|
2
|
+
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
|
|
3
|
+
import { type FetchMcpTools } from "./review.js";
|
|
4
|
+
export declare function resolveIntegrationCredentials(name: string, opts: {
|
|
5
|
+
stack?: string;
|
|
6
|
+
stage?: string;
|
|
7
|
+
tenant?: string;
|
|
8
|
+
region?: string;
|
|
9
|
+
secret?: string;
|
|
10
|
+
}, clients?: {
|
|
11
|
+
ssm?: SSMClient;
|
|
12
|
+
secretsManager?: Pick<SecretsManagerClient, "send">;
|
|
13
|
+
ensureCredentials?: (stack: string | undefined) => Promise<void>;
|
|
14
|
+
}): Promise<Record<string, string>>;
|
|
15
|
+
export declare const fetchMcpToolsOverHttp: FetchMcpTools;
|
|
16
|
+
export declare function runMcpReviewCommand(manifestPath: string, opts: {
|
|
17
|
+
yes?: boolean;
|
|
18
|
+
dryRun?: boolean;
|
|
19
|
+
stack?: string;
|
|
20
|
+
stage?: string;
|
|
21
|
+
tenant?: string;
|
|
22
|
+
region?: string;
|
|
23
|
+
secret?: string;
|
|
24
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { SSMClient } from "@aws-sdk/client-ssm";
|
|
2
|
+
import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
|
|
3
|
+
import confirm from "@inquirer/confirm";
|
|
4
|
+
import { logInfo } from "../../utils/log.js";
|
|
5
|
+
import { discoverStackExternalServices, ensureAwsCredentialsForStack, resolveExternalServiceScope, } from "../secrets/set.js";
|
|
6
|
+
import { readMcpManifestFile } from "./manifest-file.js";
|
|
7
|
+
import { reviewMcpCatalog } from "./review.js";
|
|
8
|
+
export async function resolveIntegrationCredentials(name, opts, clients = {}) {
|
|
9
|
+
const region = opts.region ?? process.env.AWS_REGION ?? "us-east-2";
|
|
10
|
+
let secretId = opts.secret;
|
|
11
|
+
if (secretId) {
|
|
12
|
+
const ensure = clients.ensureCredentials ?? ensureAwsCredentialsForStack;
|
|
13
|
+
await ensure(resolveExternalServiceScope(opts).stack);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
const { scope, services } = await discoverStackExternalServices(opts, clients.ssm ?? new SSMClient({ region }), clients.ensureCredentials ? { ensureCredentials: clients.ensureCredentials } : {});
|
|
17
|
+
const service = services.get(name);
|
|
18
|
+
if (!service?.meta?.secretPrefix) {
|
|
19
|
+
throw new Error(`No deployed ExternalService named "${name}" in ${scope.context.tenant}/${scope.platformEnv} (stack ${scope.stack ?? "unknown"}). Deploy the Integration first, or pass --secret <id>.`);
|
|
20
|
+
}
|
|
21
|
+
secretId = service.meta.secretPrefix;
|
|
22
|
+
}
|
|
23
|
+
const response = await (clients.secretsManager ?? new SecretsManagerClient({ region })).send(new GetSecretValueCommand({ SecretId: secretId }));
|
|
24
|
+
if (!response.SecretString) {
|
|
25
|
+
throw new Error(`The Integration credential at "${secretId}" is empty — populate it with \`mesh secrets set external/${name}\``);
|
|
26
|
+
}
|
|
27
|
+
return JSON.parse(response.SecretString);
|
|
28
|
+
}
|
|
29
|
+
export const fetchMcpToolsOverHttp = async ({ serverUrl, headers, timeoutMs }) => {
|
|
30
|
+
const { Client, StreamableHTTPClientTransport } = await import("@modelcontextprotocol/client");
|
|
31
|
+
const client = new Client({ name: "mesh-mcp-review", version: "1.0.0" }, { capabilities: {}, versionNegotiation: { mode: "auto" } });
|
|
32
|
+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
|
33
|
+
requestInit: { headers, redirect: "error" },
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
await client.connect(transport, { timeout: timeoutMs });
|
|
37
|
+
const result = await client.listTools(undefined, { timeout: timeoutMs });
|
|
38
|
+
return result.tools;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
if (transport.sessionId)
|
|
42
|
+
await transport.terminateSession().catch(() => { });
|
|
43
|
+
await client.close().catch(() => { });
|
|
44
|
+
await transport.close().catch(() => { });
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
export async function runMcpReviewCommand(manifestPath, opts) {
|
|
48
|
+
const { manifest } = readMcpManifestFile(manifestPath);
|
|
49
|
+
const credentials = manifest.auth.kind === "shared-header"
|
|
50
|
+
? await resolveIntegrationCredentials(manifest.name, opts)
|
|
51
|
+
: {};
|
|
52
|
+
if (manifest.auth.kind === "none") {
|
|
53
|
+
logInfo(`${manifest.name} declares no authentication; connecting unauthenticated.`);
|
|
54
|
+
}
|
|
55
|
+
const result = await reviewMcpCatalog({
|
|
56
|
+
manifestPath,
|
|
57
|
+
credentials,
|
|
58
|
+
fetchTools: fetchMcpToolsOverHttp,
|
|
59
|
+
...(opts.yes ? { yes: true } : {}),
|
|
60
|
+
...(opts.dryRun ? { dryRun: true } : {}),
|
|
61
|
+
confirm: (question) => confirm({ message: question, default: false }),
|
|
62
|
+
});
|
|
63
|
+
if (result.written) {
|
|
64
|
+
logInfo("Commit the snapshot with the regenerated worker package; McpIntegration refuses a package that does not match its manifest.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type McpManifest, type McpTool, type McpToolAccess } from "@mesh-tech/api-registry/mcp-manifest";
|
|
2
|
+
export interface ToolDiffEntry {
|
|
3
|
+
name: string;
|
|
4
|
+
status: "added" | "removed" | "changed" | "unchanged";
|
|
5
|
+
access: McpToolAccess;
|
|
6
|
+
suggested?: "read";
|
|
7
|
+
detail?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SnapshotDiff {
|
|
10
|
+
entries: ToolDiffEntry[];
|
|
11
|
+
added: string[];
|
|
12
|
+
removed: string[];
|
|
13
|
+
changed: string[];
|
|
14
|
+
removedEnabled: string[];
|
|
15
|
+
clean: boolean;
|
|
16
|
+
next: McpTool[];
|
|
17
|
+
}
|
|
18
|
+
export declare function diffSnapshot(current: readonly McpTool[], fetched: readonly McpTool[], enabledOps?: readonly string[], classification?: Record<string, McpToolAccess>): SnapshotDiff;
|
|
19
|
+
export type FetchMcpTools = (input: {
|
|
20
|
+
serverUrl: string;
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
timeoutMs: number;
|
|
23
|
+
}) => Promise<unknown>;
|
|
24
|
+
export type ConfirmFn = (question: string) => Promise<boolean>;
|
|
25
|
+
export interface ReviewMcpCatalogOptions {
|
|
26
|
+
manifestPath: string;
|
|
27
|
+
credentials: Readonly<Record<string, string>>;
|
|
28
|
+
fetchTools: FetchMcpTools;
|
|
29
|
+
confirm?: ConfirmFn;
|
|
30
|
+
report?: (line: string) => void;
|
|
31
|
+
yes?: boolean;
|
|
32
|
+
dryRun?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface ReviewMcpCatalogResult {
|
|
35
|
+
diff: SnapshotDiff;
|
|
36
|
+
manifest: McpManifest;
|
|
37
|
+
snapshotPath: string;
|
|
38
|
+
written: boolean;
|
|
39
|
+
}
|
|
40
|
+
export declare function reviewMcpCatalog(opts: ReviewMcpCatalogOptions): Promise<ReviewMcpCatalogResult>;
|
|
41
|
+
export declare function assertEnabledPresent(manifestName: string, enabledOps: readonly string[], catalog: readonly McpTool[]): void;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { classifyMcpTools, mcpAuthHeaders, parseMcpToolSnapshot, } from "@mesh-tech/api-registry/mcp-manifest";
|
|
3
|
+
import { defaultServeDir, generateMcpServeDir } from "./generate.js";
|
|
4
|
+
import { readMcpManifestFile } from "./manifest-file.js";
|
|
5
|
+
function canonical(tool) {
|
|
6
|
+
return JSON.stringify(tool, Object.keys(tool).sort());
|
|
7
|
+
}
|
|
8
|
+
function describeChange(before, after) {
|
|
9
|
+
const parts = [];
|
|
10
|
+
if (before.description !== after.description)
|
|
11
|
+
parts.push("description");
|
|
12
|
+
if (JSON.stringify(before.inputSchema) !== JSON.stringify(after.inputSchema))
|
|
13
|
+
parts.push("input schema");
|
|
14
|
+
if (JSON.stringify(before.outputSchema) !== JSON.stringify(after.outputSchema))
|
|
15
|
+
parts.push("output schema");
|
|
16
|
+
if (JSON.stringify(before.annotations) !== JSON.stringify(after.annotations))
|
|
17
|
+
parts.push("annotations (read/write classification)");
|
|
18
|
+
return parts.length > 0 ? parts.join(", ") : "other fields";
|
|
19
|
+
}
|
|
20
|
+
export function diffSnapshot(current, fetched, enabledOps = [], classification = {}) {
|
|
21
|
+
const before = new Map(current.map((t) => [t.name, t]));
|
|
22
|
+
const after = new Map(fetched.map((t) => [t.name, t]));
|
|
23
|
+
const classified = {
|
|
24
|
+
...classifyMcpTools(current, classification),
|
|
25
|
+
...classifyMcpTools(fetched, classification),
|
|
26
|
+
};
|
|
27
|
+
const accessOf = (name) => {
|
|
28
|
+
const entry = classified[name];
|
|
29
|
+
return { access: entry.access, ...(entry.suggested ? { suggested: entry.suggested } : {}) };
|
|
30
|
+
};
|
|
31
|
+
const entries = [];
|
|
32
|
+
for (const tool of fetched) {
|
|
33
|
+
const prior = before.get(tool.name);
|
|
34
|
+
if (!prior) {
|
|
35
|
+
entries.push({ name: tool.name, status: "added", ...accessOf(tool.name) });
|
|
36
|
+
}
|
|
37
|
+
else if (canonical(prior) !== canonical(tool)) {
|
|
38
|
+
entries.push({
|
|
39
|
+
name: tool.name,
|
|
40
|
+
status: "changed",
|
|
41
|
+
...accessOf(tool.name),
|
|
42
|
+
detail: describeChange(prior, tool),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
entries.push({ name: tool.name, status: "unchanged", ...accessOf(tool.name) });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const tool of current) {
|
|
50
|
+
if (!after.has(tool.name)) {
|
|
51
|
+
entries.push({ name: tool.name, status: "removed", ...accessOf(tool.name) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const pick = (status) => entries.filter((e) => e.status === status).map((e) => e.name);
|
|
55
|
+
const removed = pick("removed");
|
|
56
|
+
return {
|
|
57
|
+
entries,
|
|
58
|
+
added: pick("added"),
|
|
59
|
+
changed: pick("changed"),
|
|
60
|
+
removed,
|
|
61
|
+
removedEnabled: enabledOps.filter((op) => removed.includes(op)),
|
|
62
|
+
clean: pick("added").length === 0 && pick("changed").length === 0 && removed.length === 0,
|
|
63
|
+
next: [...fetched],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export async function reviewMcpCatalog(opts) {
|
|
67
|
+
const loaded = readMcpManifestFile(opts.manifestPath);
|
|
68
|
+
const { manifest } = loaded;
|
|
69
|
+
const report = opts.report ?? ((line) => console.log(line));
|
|
70
|
+
const snapshotPath = loaded.toolsPath;
|
|
71
|
+
const headers = mcpAuthHeaders(manifest, opts.credentials);
|
|
72
|
+
const fetched = parseMcpToolSnapshot(await opts.fetchTools({
|
|
73
|
+
serverUrl: manifest.serverUrl,
|
|
74
|
+
headers,
|
|
75
|
+
timeoutMs: manifest.timeoutMs ?? 30_000,
|
|
76
|
+
}), `${manifest.serverUrl} tools/list`);
|
|
77
|
+
if (fetched.length === 0) {
|
|
78
|
+
throw new Error(`${manifest.name}: the server answered tools/list with an empty catalog — refusing to replace a reviewed snapshot with nothing`);
|
|
79
|
+
}
|
|
80
|
+
const enabled = manifest.enabledOps ?? loaded.tools.map((t) => t.name);
|
|
81
|
+
const diff = diffSnapshot(loaded.tools, fetched, enabled, manifest.classification ?? {});
|
|
82
|
+
report(`${manifest.name} — ${manifest.serverUrl}`);
|
|
83
|
+
report(` snapshot: ${snapshotPath}`);
|
|
84
|
+
for (const entry of diff.entries) {
|
|
85
|
+
const mark = entry.status === "added"
|
|
86
|
+
? "+"
|
|
87
|
+
: entry.status === "removed"
|
|
88
|
+
? "-"
|
|
89
|
+
: entry.status === "changed"
|
|
90
|
+
? "~"
|
|
91
|
+
: " ";
|
|
92
|
+
const enabledMark = enabled.includes(entry.name) ? "enabled" : "not enabled";
|
|
93
|
+
const suggestion = entry.suggested ? ", vendor suggests read" : "";
|
|
94
|
+
report(` ${mark} ${entry.name} [${entry.access}${suggestion}, ${enabledMark}]${entry.detail ? ` — ${entry.detail}` : ""}`);
|
|
95
|
+
}
|
|
96
|
+
const suggestedReads = diff.entries
|
|
97
|
+
.filter((entry) => entry.suggested === "read" && entry.status !== "removed")
|
|
98
|
+
.map((entry) => entry.name);
|
|
99
|
+
if (suggestedReads.length > 0) {
|
|
100
|
+
report(` the vendor marks ${suggestedReads.join(", ")} read-only (readOnlyHint). They stay unknown and need approval until the manifest's "classification" names them "read".`);
|
|
101
|
+
}
|
|
102
|
+
if (diff.added.length > 0) {
|
|
103
|
+
report(` ${diff.added.length} new upstream tool(s) will be recorded but NOT enabled: add them to enabledOps deliberately.`);
|
|
104
|
+
}
|
|
105
|
+
if (diff.removedEnabled.length > 0) {
|
|
106
|
+
throw new Error(`${manifest.name}: the server no longer offers enabled operation(s) ${diff.removedEnabled.join(", ")} — remove them from enabledOps first, then re-review`);
|
|
107
|
+
}
|
|
108
|
+
if (diff.clean) {
|
|
109
|
+
report(" no changes — the reviewed snapshot still matches the server.");
|
|
110
|
+
return { diff, manifest, snapshotPath, written: false };
|
|
111
|
+
}
|
|
112
|
+
if (opts.dryRun) {
|
|
113
|
+
report(" --dry-run: nothing written.");
|
|
114
|
+
return { diff, manifest, snapshotPath, written: false };
|
|
115
|
+
}
|
|
116
|
+
const approved = opts.yes === true ||
|
|
117
|
+
(await (opts.confirm ?? (async () => false))(`Write the new snapshot to ${snapshotPath}?`));
|
|
118
|
+
if (!approved) {
|
|
119
|
+
report(" not written.");
|
|
120
|
+
return { diff, manifest, snapshotPath, written: false };
|
|
121
|
+
}
|
|
122
|
+
fs.writeFileSync(snapshotPath, `${JSON.stringify(diff.next, null, 2)}\n`);
|
|
123
|
+
report(` wrote ${diff.next.length} reviewed tool(s).`);
|
|
124
|
+
const serveDir = defaultServeDir(opts.manifestPath, manifest.name);
|
|
125
|
+
if (fs.existsSync(serveDir)) {
|
|
126
|
+
const generated = generateMcpServeDir(opts.manifestPath);
|
|
127
|
+
report(` regenerated ${generated.written.join(", ") || "nothing"} in ${generated.serveDir}.`);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
report(` run \`mesh mcp generate ${opts.manifestPath}\` to write the worker package.`);
|
|
131
|
+
}
|
|
132
|
+
return { diff, manifest, snapshotPath, written: true };
|
|
133
|
+
}
|
|
134
|
+
export function assertEnabledPresent(manifestName, enabledOps, catalog) {
|
|
135
|
+
const names = new Set(catalog.map((t) => t.name));
|
|
136
|
+
const missing = enabledOps.filter((op) => !names.has(op));
|
|
137
|
+
if (missing.length > 0) {
|
|
138
|
+
throw new Error(`${manifestName}: cannot enable ${missing.join(", ")} — absent from the reviewed snapshot`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface WorkspaceMembership {
|
|
2
|
+
workspaceFile?: string;
|
|
3
|
+
member: boolean;
|
|
4
|
+
suggestedGlob?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function matchesWorkspaceGlob(pattern: string, relDir: string): boolean;
|
|
7
|
+
export declare function workspaceMembership(dir: string): WorkspaceMembership;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
function findWorkspaceFile(from) {
|
|
5
|
+
let dir = from;
|
|
6
|
+
for (;;) {
|
|
7
|
+
const file = path.join(dir, "pnpm-workspace.yaml");
|
|
8
|
+
if (fs.existsSync(file))
|
|
9
|
+
return file;
|
|
10
|
+
const parent = path.dirname(dir);
|
|
11
|
+
if (parent === dir)
|
|
12
|
+
return undefined;
|
|
13
|
+
dir = parent;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function segmentPattern(segment) {
|
|
17
|
+
const body = segment
|
|
18
|
+
.split("")
|
|
19
|
+
.map((ch) => ch === "*" ? "[^/]*" : ch === "?" ? "[^/]" : ch.replace(/[.+^${}()|[\]\\]/g, "\\$&"))
|
|
20
|
+
.join("");
|
|
21
|
+
return new RegExp(segment.startsWith(".") ? `^${body}$` : `^(?!\\.)${body}$`);
|
|
22
|
+
}
|
|
23
|
+
function matchSegments(pattern, target) {
|
|
24
|
+
if (pattern.length === 0)
|
|
25
|
+
return target.length === 0;
|
|
26
|
+
const [head, ...rest] = pattern;
|
|
27
|
+
if (head === "**") {
|
|
28
|
+
for (let i = 0; i <= target.length; i++) {
|
|
29
|
+
if (target.slice(0, i).some((s) => s.startsWith(".")))
|
|
30
|
+
break;
|
|
31
|
+
if (matchSegments(rest, target.slice(i)))
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (target.length === 0)
|
|
37
|
+
return false;
|
|
38
|
+
return segmentPattern(head).test(target[0]) && matchSegments(rest, target.slice(1));
|
|
39
|
+
}
|
|
40
|
+
export function matchesWorkspaceGlob(pattern, relDir) {
|
|
41
|
+
const clean = (p) => p
|
|
42
|
+
.replace(/^\.\//, "")
|
|
43
|
+
.replace(/\/+$/, "")
|
|
44
|
+
.split("/")
|
|
45
|
+
.filter((s) => s.length > 0 && s !== ".");
|
|
46
|
+
return matchSegments(clean(pattern), clean(relDir.split(path.sep).join("/")));
|
|
47
|
+
}
|
|
48
|
+
export function workspaceMembership(dir) {
|
|
49
|
+
const abs = path.resolve(dir);
|
|
50
|
+
const workspaceFile = findWorkspaceFile(abs);
|
|
51
|
+
if (!workspaceFile)
|
|
52
|
+
return { member: false };
|
|
53
|
+
const root = path.dirname(workspaceFile);
|
|
54
|
+
const rel = path.relative(root, abs).split(path.sep).join("/");
|
|
55
|
+
const suggestedGlob = `${path.posix.dirname(rel)}/*`;
|
|
56
|
+
let globs;
|
|
57
|
+
try {
|
|
58
|
+
globs = parse(fs.readFileSync(workspaceFile, "utf8"))
|
|
59
|
+
?.packages;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { workspaceFile, member: false, suggestedGlob };
|
|
63
|
+
}
|
|
64
|
+
let member = false;
|
|
65
|
+
for (const glob of Array.isArray(globs) ? globs : []) {
|
|
66
|
+
if (typeof glob !== "string")
|
|
67
|
+
continue;
|
|
68
|
+
if (glob.startsWith("!")) {
|
|
69
|
+
if (matchesWorkspaceGlob(glob.slice(1), rel))
|
|
70
|
+
member = false;
|
|
71
|
+
}
|
|
72
|
+
else if (matchesWorkspaceGlob(glob, rel)) {
|
|
73
|
+
member = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { workspaceFile, member, suggestedGlob };
|
|
77
|
+
}
|
|
@@ -2,9 +2,7 @@ import { SSMClient } from "@aws-sdk/client-ssm";
|
|
|
2
2
|
import { SecretsManagerClient, ListSecretsCommand, GetSecretValueCommand, PutSecretValueCommand, CreateSecretCommand, } from "@aws-sdk/client-secrets-manager";
|
|
3
3
|
import { buildInstanceIndexValue, instanceIndexSecretId, instanceKeyFromSecretName, parseInstanceIndex, } from "@mesh-tech/secrets";
|
|
4
4
|
import { logInfo, logSuccess, logError } from "../../utils/log.js";
|
|
5
|
-
import {
|
|
6
|
-
import { resolveSecretsContext } from "./context.js";
|
|
7
|
-
import { discoverExternalServices, ensureAwsCredentialsForStack, isResourceNotFound, } from "./set.js";
|
|
5
|
+
import { discoverStackExternalServices, isResourceNotFound } from "./set.js";
|
|
8
6
|
export async function scanInstanceKeys(client, secretPrefix) {
|
|
9
7
|
const keys = [];
|
|
10
8
|
let nextToken;
|
|
@@ -26,13 +24,10 @@ export function indexAlreadyMatches(missing, stale, indexExists, malformed) {
|
|
|
26
24
|
return missing.length === 0 && stale.length === 0 && indexExists && !malformed;
|
|
27
25
|
}
|
|
28
26
|
export async function reindexCommand(servicePath, opts) {
|
|
29
|
-
const stackOpt = resolveStackOption(opts);
|
|
30
|
-
const context = resolveSecretsContext(opts, stackOpt);
|
|
31
27
|
const region = opts.region ?? process.env.AWS_REGION ?? "us-east-2";
|
|
32
|
-
await ensureAwsCredentialsForStack(stackOpt ?? context.stage);
|
|
33
28
|
const ssmClient = new SSMClient({ region });
|
|
29
|
+
const { services } = await discoverStackExternalServices(opts, ssmClient);
|
|
34
30
|
const smClient = new SecretsManagerClient({ region });
|
|
35
|
-
const services = await discoverExternalServices(ssmClient, context.tenant, context.platformEnv);
|
|
36
31
|
const serviceName = servicePath?.replace(/^external\//, "");
|
|
37
32
|
if (!serviceName || !services.has(serviceName)) {
|
|
38
33
|
if (serviceName) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { SSMClient } from "@aws-sdk/client-ssm";
|
|
2
2
|
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
|
|
3
|
+
import { type StackSelector } from "../../utils/stack-flag.js";
|
|
4
|
+
import type { MeshContext } from "../../utils/context.js";
|
|
3
5
|
export declare function ensureAwsCredentialsForStack(stack: string | undefined): Promise<void>;
|
|
4
6
|
interface SchemaField {
|
|
5
7
|
type: "field";
|
|
@@ -25,10 +27,29 @@ export interface ExternalServiceMeta {
|
|
|
25
27
|
description: string;
|
|
26
28
|
secretPrefix: string;
|
|
27
29
|
}
|
|
28
|
-
export declare function discoverExternalServices(ssmClient: SSMClient, tenant: string, platformEnv: string): Promise<Map<string, {
|
|
30
|
+
export declare function discoverExternalServices(ssmClient: SSMClient, tenant: string, platformEnv: string, stack?: string): Promise<Map<string, {
|
|
29
31
|
meta: ExternalServiceMeta;
|
|
30
32
|
schema?: CredentialSchema;
|
|
31
33
|
}>>;
|
|
34
|
+
export interface ExternalServiceScope {
|
|
35
|
+
context: MeshContext;
|
|
36
|
+
stack: string | undefined;
|
|
37
|
+
platformEnv: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function resolveExternalServiceScope(opts: StackSelector & {
|
|
40
|
+
tenant?: string;
|
|
41
|
+
}): ExternalServiceScope;
|
|
42
|
+
export declare function discoverStackExternalServices(opts: StackSelector & {
|
|
43
|
+
tenant?: string;
|
|
44
|
+
}, ssmClient: SSMClient, deps?: {
|
|
45
|
+
ensureCredentials?: (stack: string | undefined) => Promise<void>;
|
|
46
|
+
}): Promise<{
|
|
47
|
+
scope: ExternalServiceScope;
|
|
48
|
+
services: Map<string, {
|
|
49
|
+
meta: ExternalServiceMeta;
|
|
50
|
+
schema?: CredentialSchema;
|
|
51
|
+
}>;
|
|
52
|
+
}>;
|
|
32
53
|
export declare function setCommand(servicePath: string | undefined, opts: {
|
|
33
54
|
key?: string;
|
|
34
55
|
all?: boolean;
|
|
@@ -7,7 +7,7 @@ import { logInfo, logSuccess, logError } from "../../utils/log.js";
|
|
|
7
7
|
import { resolveStackOption } from "../../utils/stack-flag.js";
|
|
8
8
|
import { resolveSecretsContext } from "./context.js";
|
|
9
9
|
import { findAppRoot, readStackConfig } from "../../utils/pulumi.js";
|
|
10
|
-
import { resolveAwsCredentials } from "../../utils/aws-auth.js";
|
|
10
|
+
import { resolveAwsCredentials, derivePlatformRef } from "../../utils/aws-auth.js";
|
|
11
11
|
import { isAwsErrorCode } from "../../utils/aws-errors.js";
|
|
12
12
|
export async function ensureAwsCredentialsForStack(stack) {
|
|
13
13
|
if (process.env.AWS_ACCESS_KEY_ID || process.env.AWS_SESSION_TOKEN)
|
|
@@ -46,9 +46,11 @@ function normalizeEntry(raw) {
|
|
|
46
46
|
function normalizeFields(raw) {
|
|
47
47
|
return Object.fromEntries(Object.entries(raw).map(([key, value]) => [key, normalizeEntry(value)]));
|
|
48
48
|
}
|
|
49
|
-
export async function discoverExternalServices(ssmClient, tenant, platformEnv) {
|
|
49
|
+
export async function discoverExternalServices(ssmClient, tenant, platformEnv, stack) {
|
|
50
50
|
const services = new Map();
|
|
51
51
|
const basePath = `/mesh-platform/${tenant}/${platformEnv}`;
|
|
52
|
+
const stackSegment = stack ? `/stacks/${stack}/` : undefined;
|
|
53
|
+
const fromStack = new Set();
|
|
52
54
|
let nextToken;
|
|
53
55
|
do {
|
|
54
56
|
const response = await ssmClient.send(new GetParametersByPathCommand({
|
|
@@ -59,9 +61,16 @@ export async function discoverExternalServices(ssmClient, tenant, platformEnv) {
|
|
|
59
61
|
for (const param of response.Parameters ?? []) {
|
|
60
62
|
if (!param.Name || !param.Value)
|
|
61
63
|
continue;
|
|
64
|
+
const scoped = param.Name.includes("/stacks/");
|
|
65
|
+
if (stackSegment && scoped && !param.Name.includes(stackSegment))
|
|
66
|
+
continue;
|
|
62
67
|
const metaMatch = param.Name.match(/\/external-services\/([^/]+)\/meta$/);
|
|
63
68
|
if (metaMatch?.[1]) {
|
|
64
69
|
const serviceName = metaMatch[1];
|
|
70
|
+
if (stackSegment && !scoped && fromStack.has(`meta:${serviceName}`))
|
|
71
|
+
continue;
|
|
72
|
+
if (stackSegment && scoped)
|
|
73
|
+
fromStack.add(`meta:${serviceName}`);
|
|
65
74
|
const meta = JSON.parse(param.Value);
|
|
66
75
|
const existing = services.get(serviceName);
|
|
67
76
|
services.set(serviceName, { ...existing, meta });
|
|
@@ -69,6 +78,10 @@ export async function discoverExternalServices(ssmClient, tenant, platformEnv) {
|
|
|
69
78
|
const credMatch = param.Name.match(/\/external-services\/([^/]+)\/credentials$/);
|
|
70
79
|
if (credMatch?.[1]) {
|
|
71
80
|
const serviceName = credMatch[1];
|
|
81
|
+
if (stackSegment && !scoped && fromStack.has(`schema:${serviceName}`))
|
|
82
|
+
continue;
|
|
83
|
+
if (stackSegment && scoped)
|
|
84
|
+
fromStack.add(`schema:${serviceName}`);
|
|
72
85
|
const raw = JSON.parse(param.Value);
|
|
73
86
|
const schema = {
|
|
74
87
|
fields: normalizeFields(raw.fields),
|
|
@@ -146,17 +159,27 @@ function displaySummary(fields, values, depth = 0) {
|
|
|
146
159
|
}
|
|
147
160
|
}
|
|
148
161
|
}
|
|
149
|
-
export
|
|
162
|
+
export function resolveExternalServiceScope(opts) {
|
|
150
163
|
const stackOpt = resolveStackOption(opts);
|
|
151
164
|
const context = resolveSecretsContext(opts, stackOpt);
|
|
165
|
+
const stack = stackOpt ?? context.stage;
|
|
166
|
+
const appRoot = findAppRoot(process.cwd());
|
|
167
|
+
const platformEnv = (appRoot && stack ? derivePlatformRef(appRoot, stack)?.env : undefined) ?? context.platformEnv;
|
|
168
|
+
return { context, stack, platformEnv };
|
|
169
|
+
}
|
|
170
|
+
export async function discoverStackExternalServices(opts, ssmClient, deps = {}) {
|
|
171
|
+
const scope = resolveExternalServiceScope(opts);
|
|
172
|
+
await (deps.ensureCredentials ?? ensureAwsCredentialsForStack)(scope.stack);
|
|
173
|
+
const services = await discoverExternalServices(ssmClient, scope.context.tenant, scope.platformEnv, scope.stack);
|
|
174
|
+
return { scope, services };
|
|
175
|
+
}
|
|
176
|
+
export async function setCommand(servicePath, opts) {
|
|
152
177
|
const region = opts.region ?? process.env.AWS_REGION ?? "us-east-2";
|
|
153
|
-
await
|
|
154
|
-
const ssmClient = new SSMClient({ region });
|
|
178
|
+
const { services } = await discoverStackExternalServices(opts, new SSMClient({ region }));
|
|
155
179
|
const smClient = new SecretsManagerClient({ region });
|
|
156
|
-
const services = await discoverExternalServices(ssmClient, context.tenant, context.platformEnv);
|
|
157
180
|
if (services.size === 0) {
|
|
158
181
|
logError("No external services found. Deploy an ExternalService first:\n" +
|
|
159
|
-
|
|
182
|
+
' new mesh.apps.ExternalService("symitar", { env, credentials: { ... } })');
|
|
160
183
|
process.exit(1);
|
|
161
184
|
}
|
|
162
185
|
let serviceName;
|
|
@@ -212,7 +235,7 @@ export async function setCommand(servicePath, opts) {
|
|
|
212
235
|
values = JSON.parse(opts.json);
|
|
213
236
|
}
|
|
214
237
|
catch {
|
|
215
|
-
logError(
|
|
238
|
+
logError('Invalid JSON input. Provide valid JSON, e.g.: --json=\'{"key": "value"}\'');
|
|
216
239
|
return;
|
|
217
240
|
}
|
|
218
241
|
const merged = deepMerge(existing, values);
|