localdeck 1.4.0 → 1.6.1
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/CHANGELOG.md +47 -0
- package/README.md +4 -1
- package/THIRD_PARTY_NOTICES.md +69 -0
- package/dashboard/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dashboard/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dashboard/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dashboard/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dashboard/assets/index-C9iZV9gt.js +21 -0
- package/dashboard/assets/index-DDw1Wq-H.css +1 -0
- package/dashboard/index.html +2 -2
- package/dist/{index-316fp4p2.js → index-2zg5ahh5.js} +2289 -1337
- package/dist/index.js +98 -13
- package/dist/{mcp-h8gf5pgj.js → mcp-r1ygwec0.js} +96 -10
- package/dist/{update-w6vc3sdy.js → update-n1fnecgj.js} +1 -1
- package/docs/commands.md +15 -2
- package/docs/configuration.md +42 -6
- package/docs/dashboard.md +60 -20
- package/docs/mcp.md +29 -5
- package/package.json +1 -1
- package/dashboard/assets/index-2ZVKTtzC.css +0 -1
- package/dashboard/assets/index-D9Moyhwp.js +0 -21
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
HOME_DIR,
|
|
5
5
|
VERSION,
|
|
6
6
|
__require,
|
|
7
|
+
applyProfiles,
|
|
7
8
|
chooseUpstream,
|
|
8
9
|
connectHost,
|
|
9
10
|
dashboardDir,
|
|
@@ -31,7 +32,7 @@ import {
|
|
|
31
32
|
unreachableListenerMessage,
|
|
32
33
|
waitForReadiness,
|
|
33
34
|
wrapper_default
|
|
34
|
-
} from "./index-
|
|
35
|
+
} from "./index-2zg5ahh5.js";
|
|
35
36
|
|
|
36
37
|
// src/index.ts
|
|
37
38
|
import { spawn as spawn2 } from "node:child_process";
|
|
@@ -155,7 +156,8 @@ async function startService(opts, overrides = {}) {
|
|
|
155
156
|
upstreamPort,
|
|
156
157
|
upstreamSource: opts.upstreamPort === undefined ? "assigned" : "manual",
|
|
157
158
|
cwd,
|
|
158
|
-
command: opts.displayCommand ?? opts.command.join(" ")
|
|
159
|
+
command: opts.displayCommand ?? opts.command.join(" "),
|
|
160
|
+
...opts.project ? { profiles: opts.profiles ?? [] } : {}
|
|
159
161
|
});
|
|
160
162
|
});
|
|
161
163
|
ws.on("message", (raw) => {
|
|
@@ -849,7 +851,7 @@ ${c.bold("Usage")}
|
|
|
849
851
|
localdeck run <name> [--port 4000] [--upstream 5173] [--] <command...>
|
|
850
852
|
localdeck <name> run a command from the project's .localdeck file
|
|
851
853
|
localdeck up [tag|name...] run every .localdeck command (or just the tagged/named ones) in one terminal
|
|
852
|
-
localdeck
|
|
854
|
+
localdeck profile [on|off <name...>|clear] list or switch this project's profiles
|
|
853
855
|
localdeck init [--dry-run] [--force] preview/create/replace the project configuration
|
|
854
856
|
localdeck init --name web --command 'your command'
|
|
855
857
|
localdeck commands list configured services (alias: cmds)
|
|
@@ -915,13 +917,16 @@ async function main(argv, overrides = {}) {
|
|
|
915
917
|
case "--update":
|
|
916
918
|
if (rest.length)
|
|
917
919
|
throw new Error("Usage: localdeck --update");
|
|
918
|
-
return (await import("./update-
|
|
920
|
+
return (await import("./update-n1fnecgj.js")).updateLocalDeck();
|
|
919
921
|
case "up":
|
|
920
922
|
return cmdUp(rest, dependencies);
|
|
923
|
+
case "profile":
|
|
924
|
+
case "profiles":
|
|
925
|
+
return cmdProfile(rest, dependencies);
|
|
921
926
|
case "init":
|
|
922
927
|
return cmdInit(rest, dependencies);
|
|
923
928
|
case "mcp":
|
|
924
|
-
return (await import("./mcp-
|
|
929
|
+
return (await import("./mcp-r1ygwec0.js")).runMcp(rest);
|
|
925
930
|
case "doctor":
|
|
926
931
|
return cmdDoctor(rest);
|
|
927
932
|
case "forward":
|
|
@@ -961,10 +966,12 @@ async function main(argv, overrides = {}) {
|
|
|
961
966
|
const found = project && findProjectCommand(project, cmd);
|
|
962
967
|
if (found) {
|
|
963
968
|
const remembered = await rememberProject(project, dependencies);
|
|
969
|
+
const launch = await resolveLaunch(project, remembered, dependencies);
|
|
964
970
|
const selected = startupLayers(project.commands, [found.name]).flat();
|
|
965
|
-
return dependencies.runForeground(selected.map((command, index) => toRunOptions(command, project, remembered, {
|
|
971
|
+
return dependencies.runForeground(selected.map((command, index) => toRunOptions(launch.effective(command), project, remembered, {
|
|
966
972
|
prefix: selected.length > 1,
|
|
967
|
-
color: PALETTE[index % PALETTE.length]
|
|
973
|
+
color: PALETTE[index % PALETTE.length],
|
|
974
|
+
...launch.options
|
|
968
975
|
})));
|
|
969
976
|
}
|
|
970
977
|
const hint = project ? ` Commands in ${path3.basename(project.file)}: ${project.commands.map((p) => p.name).join(", ") || "(none)"}` : "";
|
|
@@ -1006,6 +1013,32 @@ async function rememberProject(project, dependencies) {
|
|
|
1006
1013
|
return;
|
|
1007
1014
|
}
|
|
1008
1015
|
}
|
|
1016
|
+
async function resolveLaunch(project, remembered, dependencies) {
|
|
1017
|
+
let active = [];
|
|
1018
|
+
if (project.profiles?.length && remembered) {
|
|
1019
|
+
try {
|
|
1020
|
+
const daemon = await dependencies.ensureDaemon();
|
|
1021
|
+
active = (await dependencies.createApi(daemon.apiPort).profiles(remembered.id)).active;
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
dependencies.warn(`LocalDeck: could not read active profiles, running without them: ${error instanceof Error ? error.message : String(error)}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
let resolved;
|
|
1027
|
+
try {
|
|
1028
|
+
resolved = applyProfiles(project, active);
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
1031
|
+
}
|
|
1032
|
+
if (resolved.active.length)
|
|
1033
|
+
dependencies.log(c.dim(`Profiles: ${resolved.active.join(" + ")}`));
|
|
1034
|
+
for (const clash of resolved.clashes)
|
|
1035
|
+
dependencies.warn(`LocalDeck: ${clash.profiles.join(" and ")} both set ${clash.service} ${clash.field}; ${clash.winner} wins because it is listed later.`);
|
|
1036
|
+
const byName = new Map(resolved.commands.map((command) => [command.name, command]));
|
|
1037
|
+
return {
|
|
1038
|
+
effective: (command) => byName.get(command.name) ?? command,
|
|
1039
|
+
options: remembered ? { profiles: resolved.active } : {}
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1009
1042
|
async function printProjectCommands(failIfNone, dependencies) {
|
|
1010
1043
|
const project = tryLoadProject(dependencies);
|
|
1011
1044
|
if (!project) {
|
|
@@ -1017,6 +1050,9 @@ async function printProjectCommands(failIfNone, dependencies) {
|
|
|
1017
1050
|
console.log(`${c.bold("Project commands")} ${c.dim(`(${path3.relative(process.cwd(), project.file) || path3.basename(project.file)})`)}`);
|
|
1018
1051
|
const rows = project.commands.map((p) => [c.cyan(p.name), p.port ? String(p.port) : c.dim("auto"), p.after.join(",") || "", p.tags.join(",") || "", c.dim(p.run)]);
|
|
1019
1052
|
console.log(table(rows, ["NAME", "PORT", "AFTER", "TAGS", "RUN"]));
|
|
1053
|
+
if (project.profiles?.length)
|
|
1054
|
+
console.log(`
|
|
1055
|
+
${c.bold("Profiles")} ${project.profiles.map((profile) => profile.name).join(", ")} ${c.dim("· localdeck profile")}`);
|
|
1020
1056
|
console.log(c.dim(`
|
|
1021
1057
|
run one: localdeck <name> · run all: localdeck up · by tag: localdeck up <tag>`));
|
|
1022
1058
|
}
|
|
@@ -1033,12 +1069,16 @@ async function cmdUp(args, dependencies) {
|
|
|
1033
1069
|
fail("--profile needs a profile name");
|
|
1034
1070
|
args = args.filter((_, i) => i !== profileIndex && i !== profileIndex + 1);
|
|
1035
1071
|
}
|
|
1036
|
-
const
|
|
1037
|
-
if (profile && (
|
|
1038
|
-
fail(`
|
|
1072
|
+
const legacy = project.legacyProfiles;
|
|
1073
|
+
if (profile && project.profiles?.some((candidate) => candidate.name === profile))
|
|
1074
|
+
fail(`"${profile}" is a profile you turn on, not a group to start: localdeck profile on ${profile}`);
|
|
1075
|
+
if (profile && (!legacy || !Object.prototype.hasOwnProperty.call(legacy, profile)))
|
|
1076
|
+
fail(`unknown profile "${profile}". Group services with tags and run: localdeck up <tag>`);
|
|
1077
|
+
if (profile)
|
|
1078
|
+
dependencies.warn(`LocalDeck: --profile is deprecated. "${profile}" is now a tag; run: localdeck up ${profile}`);
|
|
1039
1079
|
let selected = project.commands;
|
|
1040
1080
|
if (profile) {
|
|
1041
|
-
const names = new Set(
|
|
1081
|
+
const names = new Set(legacy[profile]);
|
|
1042
1082
|
selected = project.commands.filter((command) => names.has(command.name));
|
|
1043
1083
|
if (!selected.length)
|
|
1044
1084
|
fail(`profile "${profile}" selects no configured services`);
|
|
@@ -1058,7 +1098,50 @@ async function cmdUp(args, dependencies) {
|
|
|
1058
1098
|
fail("nothing to run");
|
|
1059
1099
|
selected = startupLayers(project.commands, selected.map((command) => command.name)).flat();
|
|
1060
1100
|
const remembered = await rememberProject(project, dependencies);
|
|
1061
|
-
|
|
1101
|
+
const launch = await resolveLaunch(project, remembered, dependencies);
|
|
1102
|
+
await dependencies.runForeground(selected.map((p, i) => toRunOptions(launch.effective(p), project, remembered, { prefix: selected.length > 1, color: PALETTE[i % PALETTE.length], ...launch.options })));
|
|
1103
|
+
}
|
|
1104
|
+
async function cmdProfile(args, dependencies) {
|
|
1105
|
+
const project = tryLoadProject(dependencies);
|
|
1106
|
+
if (!project)
|
|
1107
|
+
fail("no .localdeck file found here or in a parent folder. Create one with: localdeck init");
|
|
1108
|
+
const [action = "list", ...names] = args;
|
|
1109
|
+
if (!["list", "ls", "on", "off", "clear"].includes(action))
|
|
1110
|
+
fail(`unknown profile action "${action}". Usage: localdeck profile [on|off <name...>|clear]`);
|
|
1111
|
+
if ((action === "on" || action === "off") && !names.length)
|
|
1112
|
+
fail(`localdeck profile ${action} needs at least one profile name`);
|
|
1113
|
+
if (!project.profiles?.length) {
|
|
1114
|
+
if (action === "list" || action === "ls") {
|
|
1115
|
+
dependencies.log(`No profiles in ${path3.basename(project.file)}. Add a "profiles" object to run services differently, for example against staging.`);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
fail(`${path3.basename(project.file)} defines no profiles`);
|
|
1119
|
+
}
|
|
1120
|
+
const remembered = await rememberProject(project, dependencies);
|
|
1121
|
+
if (!remembered)
|
|
1122
|
+
fail("the LocalDeck daemon is needed to switch profiles");
|
|
1123
|
+
const daemon = await dependencies.ensureDaemon();
|
|
1124
|
+
const api = dependencies.createApi(daemon.apiPort);
|
|
1125
|
+
let current = await api.profiles(remembered.id);
|
|
1126
|
+
if (action === "on" || action === "off" || action === "clear") {
|
|
1127
|
+
const wanted = names.map((name) => name.toLowerCase());
|
|
1128
|
+
const unknown = wanted.filter((name) => !current.profiles.some((candidate) => candidate.name === name));
|
|
1129
|
+
if (unknown.length)
|
|
1130
|
+
fail(`unknown profile ${unknown.map((name) => `"${name}"`).join(", ")}. Available profiles: ${current.profiles.map((candidate) => candidate.name).join(", ")}`);
|
|
1131
|
+
const next = action === "clear" ? [] : action === "on" ? [...new Set([...current.active, ...wanted])] : current.active.filter((name) => !wanted.includes(name));
|
|
1132
|
+
current = await api.setActiveProfiles(remembered.id, next);
|
|
1133
|
+
dependencies.log(current.active.length ? `Profiles on: ${c.cyan(current.active.join(" + "))}` : "No profiles on · running base services");
|
|
1134
|
+
dependencies.log(c.dim("Running services keep their settings until restarted."));
|
|
1135
|
+
} else {
|
|
1136
|
+
const rows = current.profiles.map((candidate) => [candidate.active ? c.green("on") : c.dim("off"), c.cyan(candidate.name), candidate.services.join(","), candidate.description ?? ""]);
|
|
1137
|
+
dependencies.log(table(rows, ["", "PROFILE", "CHANGES", "DESCRIPTION"]));
|
|
1138
|
+
dependencies.log(c.dim(`
|
|
1139
|
+
turn on: localdeck profile on <name> · off: localdeck profile off <name> · all off: localdeck profile clear`));
|
|
1140
|
+
}
|
|
1141
|
+
for (const clash of current.clashes)
|
|
1142
|
+
dependencies.warn(`LocalDeck: ${clash.profiles.join(" and ")} both set ${clash.service} ${clash.field}; ${clash.winner} wins because it is listed later.`);
|
|
1143
|
+
if (current.error)
|
|
1144
|
+
dependencies.warn(`LocalDeck: ${current.error}`);
|
|
1062
1145
|
}
|
|
1063
1146
|
async function cmdInit(args, dependencies) {
|
|
1064
1147
|
const root = findPackageRoot() ?? process.cwd();
|
|
@@ -1185,10 +1268,12 @@ async function cmdRun(args, dependencies) {
|
|
|
1185
1268
|
const found = project && name ? findProjectCommand(project, name) : undefined;
|
|
1186
1269
|
if (found) {
|
|
1187
1270
|
const remembered = await rememberProject(project, dependencies);
|
|
1271
|
+
const launch = await resolveLaunch(project, remembered, dependencies);
|
|
1188
1272
|
const selected = startupLayers(project.commands, [found.name]).flat();
|
|
1189
|
-
return dependencies.runForeground(selected.map((command2, index) => toRunOptions(command2, project, remembered, {
|
|
1273
|
+
return dependencies.runForeground(selected.map((command2, index) => toRunOptions(launch.effective(command2), project, remembered, {
|
|
1190
1274
|
prefix: selected.length > 1,
|
|
1191
1275
|
color: PALETTE[index % PALETTE.length],
|
|
1276
|
+
...launch.options,
|
|
1192
1277
|
...command2.name === found.name ? { stablePort: opts.port ?? found.port, upstreamPort: opts.upstream ?? found.upstream } : {}
|
|
1193
1278
|
})));
|
|
1194
1279
|
}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
listProjectSecrets,
|
|
10
10
|
redactDiagnostic,
|
|
11
11
|
resolveProjectSecrets
|
|
12
|
-
} from "./index-
|
|
12
|
+
} from "./index-2zg5ahh5.js";
|
|
13
13
|
|
|
14
14
|
// ../../node_modules/.bun/ajv@8.20.0/node_modules/ajv/dist/compile/codegen/code.js
|
|
15
15
|
var require_code = __commonJS((exports) => {
|
|
@@ -33935,20 +33935,20 @@ function sanitize(value, secrets) {
|
|
|
33935
33935
|
}
|
|
33936
33936
|
return value;
|
|
33937
33937
|
}
|
|
33938
|
-
function largestString(value) {
|
|
33938
|
+
function largestString(value, preserveItems = false) {
|
|
33939
33939
|
if (!value || typeof value !== "object")
|
|
33940
33940
|
return;
|
|
33941
33941
|
let largest;
|
|
33942
33942
|
for (const [key2, item] of Object.entries(value)) {
|
|
33943
|
-
const candidate = typeof item === "string" && key2 !== "password" ? { text: item, replace: (text) => {
|
|
33943
|
+
const candidate = typeof item === "string" && key2 !== "password" && !/cursor$/i.test(key2) && !(preserveItems && /^(id|service|serviceKey)$/i.test(key2)) ? { text: item, replace: (text) => {
|
|
33944
33944
|
Reflect.set(value, key2, text);
|
|
33945
|
-
} } : largestString(item);
|
|
33945
|
+
} } : largestString(item, preserveItems);
|
|
33946
33946
|
if (candidate && (!largest || candidate.text.length > largest.text.length))
|
|
33947
33947
|
largest = candidate;
|
|
33948
33948
|
}
|
|
33949
33949
|
return largest;
|
|
33950
33950
|
}
|
|
33951
|
-
function result(data, secrets, isError = false, password) {
|
|
33951
|
+
function result(data, secrets, isError = false, password, preserveItems = false) {
|
|
33952
33952
|
const clean = sanitize(data, secrets);
|
|
33953
33953
|
if (password)
|
|
33954
33954
|
clean.password = password;
|
|
@@ -33961,7 +33961,7 @@ function result(data, secrets, isError = false, password) {
|
|
|
33961
33961
|
if (fits())
|
|
33962
33962
|
return encode3();
|
|
33963
33963
|
clean.truncated = true;
|
|
33964
|
-
if (Array.isArray(clean.items) && clean.items.length > 1) {
|
|
33964
|
+
if (!preserveItems && Array.isArray(clean.items) && clean.items.length > 1) {
|
|
33965
33965
|
const items = clean.items;
|
|
33966
33966
|
const select = (count) => clean.retainedWindow ? items.slice(-count) : items.slice(0, count);
|
|
33967
33967
|
let low = 1;
|
|
@@ -33977,7 +33977,7 @@ function result(data, secrets, isError = false, password) {
|
|
|
33977
33977
|
clean.items = select(low);
|
|
33978
33978
|
}
|
|
33979
33979
|
while (!fits()) {
|
|
33980
|
-
const largest = largestString(clean);
|
|
33980
|
+
const largest = largestString(clean, preserveItems);
|
|
33981
33981
|
if (!largest || largest.text.length < 100) {
|
|
33982
33982
|
return {
|
|
33983
33983
|
content: [{ type: "text", text: JSON.stringify({ truncated: true, error: "Result exceeds output limit; narrow the query." }) }],
|
|
@@ -34000,7 +34000,7 @@ function createMcpServer(options) {
|
|
|
34000
34000
|
const connection = await options.connect();
|
|
34001
34001
|
secrets.push(...connection.secrets ?? [], ...Object.entries(process.env).flatMap(([k, v]) => /password|secret|token|api[_-]?key|credential/i.test(k) && v ? [v] : []));
|
|
34002
34002
|
const response = await handler(args, connection.api, secrets);
|
|
34003
|
-
return result(response.data, secrets, response.data.ok === false, response.password);
|
|
34003
|
+
return result(response.data, secrets, response.data.ok === false, response.password, name === "get_timeline");
|
|
34004
34004
|
} catch (error61) {
|
|
34005
34005
|
return result({ error: error61 instanceof Error ? error61.message : "LocalDeck request failed" }, secrets, true);
|
|
34006
34006
|
}
|
|
@@ -34043,6 +34043,14 @@ function createMcpServer(options) {
|
|
|
34043
34043
|
throw new Error("Unknown service key; use list_services");
|
|
34044
34044
|
return { projectId, name, details, runtime };
|
|
34045
34045
|
}
|
|
34046
|
+
function profileState(details, name, runtime) {
|
|
34047
|
+
const current = details?.fingerprints?.[name];
|
|
34048
|
+
const running = runtime && runtime.status !== "stopped";
|
|
34049
|
+
return {
|
|
34050
|
+
...running ? { profiles: runtime.profiles ?? [] } : {},
|
|
34051
|
+
...running && runtime.configFingerprint && current ? { restartNeeded: runtime.configFingerprint !== current } : {}
|
|
34052
|
+
};
|
|
34053
|
+
}
|
|
34046
34054
|
tool("list_projects", "List remembered projects, their exact IDs and configuration availability.", { limit }, false, async (args, api2) => {
|
|
34047
34055
|
const all = (await api2.projects()).filter((p) => !options.projectId || p.id === options.projectId);
|
|
34048
34056
|
return { data: { items: all.slice(0, args.limit), truncated: all.length > args.limit } };
|
|
@@ -34063,14 +34071,34 @@ function createMcpServer(options) {
|
|
|
34063
34071
|
const details = await project(api2, summary.id, secrets);
|
|
34064
34072
|
for (const c of details.commands) {
|
|
34065
34073
|
const serviceKey = `${summary.id}:${c.name}`;
|
|
34066
|
-
|
|
34074
|
+
const existing = items.get(serviceKey);
|
|
34075
|
+
items.set(serviceKey, { key: serviceKey, projectId: summary.id, projectName: summary.name, name: c.name, status: "stopped", managedBy: "daemon", configured: true, ...existing, ...profileState(details, c.name, existing && "upstreamPort" in existing ? existing : undefined) });
|
|
34067
34076
|
}
|
|
34068
34077
|
}
|
|
34069
34078
|
return { data: { items: [...items.values()].slice(0, args.limit), truncated: items.size > args.limit, unavailable } };
|
|
34070
34079
|
});
|
|
34071
34080
|
tool("get_service", "Inspect a registered or configured service. Ports and counters appear after its first start.", serviceInput, false, async (args, api2, secrets) => {
|
|
34072
34081
|
const target = await service(api2, args.serviceKey, secrets);
|
|
34073
|
-
|
|
34082
|
+
const command = target.details?.commands.find((c) => c.name === target.name);
|
|
34083
|
+
const origin = target.details?.provenance?.[target.name];
|
|
34084
|
+
return { data: {
|
|
34085
|
+
service: { ...target.runtime ?? { key: args.serviceKey, projectId: target.projectId, name: target.name, status: "stopped", configured: true, managedBy: "daemon" }, ...profileState(target.details, target.name, target.runtime) },
|
|
34086
|
+
...command ? { configuration: {
|
|
34087
|
+
run: command.run,
|
|
34088
|
+
cwd: command.cwd ?? ".",
|
|
34089
|
+
activeProfiles: target.details?.activeProfiles ?? [],
|
|
34090
|
+
...origin ? { sources: { run: origin.run, cwd: origin.cwd, envNames: origin.env, secretNames: origin.secrets } } : {}
|
|
34091
|
+
} } : {},
|
|
34092
|
+
notice
|
|
34093
|
+
} };
|
|
34094
|
+
});
|
|
34095
|
+
tool("list_profiles", "List a project's profiles: overlays that change how services run (command, env, secrets, cwd) on this computer. Shows which are on and any clashes. Does not start anything.", { projectId: id }, false, async (args, api2, secrets) => {
|
|
34096
|
+
await project(api2, args.projectId, secrets);
|
|
34097
|
+
return { data: { ...await api2.profiles(args.projectId) } };
|
|
34098
|
+
});
|
|
34099
|
+
tool("set_profiles", "Replace the list of profiles turned on for a project on this computer. Never starts, stops or restarts services; running services keep their settings until restarted (list_services shows restartNeeded).", { projectId: id, profiles: exports_external.array(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,40}$/i)).max(64) }, true, async (args, api2, secrets) => {
|
|
34100
|
+
await project(api2, args.projectId, secrets);
|
|
34101
|
+
return { data: { ...await api2.setActiveProfiles(args.projectId, args.profiles.map((name) => name.toLowerCase())) } };
|
|
34074
34102
|
});
|
|
34075
34103
|
tool("get_logs", "Read recent logs. Filters apply to the last 200 retained records. No live follow.", { ...serviceInput, limit, stream: exports_external.enum(["stdout", "stderr", "system"]).optional() }, false, async (args, api2, secrets) => {
|
|
34076
34104
|
await service(api2, args.serviceKey, secrets);
|
|
@@ -34086,6 +34114,64 @@ function createMcpServer(options) {
|
|
|
34086
34114
|
await service(api2, args.serviceKey, secrets);
|
|
34087
34115
|
return { data: { serviceKey: args.serviceKey, requestId: args.requestId, response: await api2.response(args.serviceKey, args.requestId), notice } };
|
|
34088
34116
|
});
|
|
34117
|
+
const selectedServices = exports_external.array(key).min(1).max(100).optional();
|
|
34118
|
+
function serviceScope(projectId, services) {
|
|
34119
|
+
scope(projectId);
|
|
34120
|
+
if (services?.some((serviceKey) => serviceKey.split(":")[0] !== projectId))
|
|
34121
|
+
throw new Error("Selected services must belong to the requested project");
|
|
34122
|
+
}
|
|
34123
|
+
tool("get_timeline", "Read retained project activity, with optional exact service keys, incidents filter, text query and opaque pagination cursor. Coverage describes missing evidence; absence is not proof of recovery.", { projectId: id, services: selectedServices, filter: exports_external.enum(["all", "incidents"]).optional(), query: exports_external.string().max(200).optional(), cursor: exports_external.string().max(4096).optional(), limit }, false, async (args, api2, secrets) => {
|
|
34124
|
+
serviceScope(args.projectId, args.services);
|
|
34125
|
+
await project(api2, args.projectId, secrets);
|
|
34126
|
+
return { data: { ...await api2.timeline(args.projectId, args), notice } };
|
|
34127
|
+
});
|
|
34128
|
+
tool("mark_baseline", "Explicitly mark current retained evidence before a debugging change. Does not start services or send requests.", { projectId: id, services: selectedServices, label: exports_external.string().max(100).optional() }, true, async (args, api2, secrets) => {
|
|
34129
|
+
serviceScope(args.projectId, args.services);
|
|
34130
|
+
await project(api2, args.projectId, secrets);
|
|
34131
|
+
return { data: { ...await api2.markBaseline(args.projectId, { services: args.services, label: args.label }), notice } };
|
|
34132
|
+
});
|
|
34133
|
+
tool("list_baselines", "Read saved project baseline markers.", { projectId: id }, false, async (args, api2, secrets) => {
|
|
34134
|
+
await project(api2, args.projectId, secrets);
|
|
34135
|
+
return { data: { ...await api2.baselines(args.projectId), notice } };
|
|
34136
|
+
});
|
|
34137
|
+
tool("compare_baseline", "Compare retained evidence before and after a marker. Not observed again does not mean fixed; inspect coverage and insufficientEvidence.", { projectId: id, baselineId: id }, false, async (args, api2, secrets) => {
|
|
34138
|
+
await project(api2, args.projectId, secrets);
|
|
34139
|
+
return { data: { ...await api2.compareBaseline(args.projectId, args.baselineId), notice } };
|
|
34140
|
+
});
|
|
34141
|
+
tool("list_environments", "Read Git worktree environments for a project without registering or starting them.", { projectId: id }, false, async (args, api2, secrets) => {
|
|
34142
|
+
await project(api2, args.projectId, secrets);
|
|
34143
|
+
const discovery = await api2.environments(args.projectId);
|
|
34144
|
+
if (options.projectId) {
|
|
34145
|
+
const environments = Array.isArray(discovery.environments) ? discovery.environments.filter((environment) => environment && typeof environment === "object" && ("projectId" in environment) && environment.projectId === options.projectId) : [];
|
|
34146
|
+
return { data: { environments, notice } };
|
|
34147
|
+
}
|
|
34148
|
+
return { data: { ...discovery, notice } };
|
|
34149
|
+
});
|
|
34150
|
+
tool("register_environment", "Explicitly remember a discovered worktree environment without starting apps. Unavailable on project-restricted MCP servers because it expands project scope.", { projectId: id, environmentId: id }, true, async (args, api2, secrets) => {
|
|
34151
|
+
scope(args.projectId);
|
|
34152
|
+
if (options.projectId)
|
|
34153
|
+
throw new Error("Environment registration expands project scope and is unavailable on a project-restricted MCP server");
|
|
34154
|
+
await project(api2, args.projectId, secrets);
|
|
34155
|
+
return { data: { ...await api2.registerEnvironment(args.projectId, args.environmentId), notice } };
|
|
34156
|
+
});
|
|
34157
|
+
tool("export_request", "Export a redacted reproduction of a recorded request without executing it. Inspect completeness and omitted credentials before use.", { ...serviceInput, requestId: id }, false, async (args, api2, secrets) => {
|
|
34158
|
+
await service(api2, args.serviceKey, secrets);
|
|
34159
|
+
return { data: { ...await api2.exportRequest(args.serviceKey, args.requestId), notice } };
|
|
34160
|
+
});
|
|
34161
|
+
tool("save_check", "Explicitly save a recorded GET/HEAD as a status check without running it. Credentials and arbitrary destinations cannot be supplied.", { projectId: id, ...serviceInput, requestId: id, name: exports_external.string().min(1).max(200), expectedStatus: exports_external.number().int().min(100).max(599) }, true, async (args, api2, secrets) => {
|
|
34162
|
+
serviceScope(args.projectId, [args.serviceKey]);
|
|
34163
|
+
await project(api2, args.projectId, secrets);
|
|
34164
|
+
await service(api2, args.serviceKey, secrets);
|
|
34165
|
+
return { data: { ...await api2.saveCheck(args.projectId, { serviceKey: args.serviceKey, requestId: args.requestId, name: args.name, expectedStatus: args.expectedStatus }), notice } };
|
|
34166
|
+
});
|
|
34167
|
+
tool("list_checks", "Read saved project status checks and their retained results without running requests.", { projectId: id }, false, async (args, api2, secrets) => {
|
|
34168
|
+
await project(api2, args.projectId, secrets);
|
|
34169
|
+
return { data: { ...await api2.checks(args.projectId), notice } };
|
|
34170
|
+
});
|
|
34171
|
+
tool("run_check", "Explicitly run one saved GET/HEAD check against its current local service. Even GET can have side effects. No automatic retries.", { projectId: id, checkId: id }, true, async (args, api2, secrets) => {
|
|
34172
|
+
await project(api2, args.projectId, secrets);
|
|
34173
|
+
return { data: { ...await api2.runCheck(args.projectId, args.checkId), notice } };
|
|
34174
|
+
});
|
|
34089
34175
|
for (const action of ["start", "restart"])
|
|
34090
34176
|
tool(`${action}_service`, `${action === "start" ? "Start a configured service and its prerequisites in the background" : "Restart a daemon-owned configured service; terminal-owned services must be restarted in their terminal"}. May execute project commands.`, serviceInput, true, async (args, api2, secrets) => {
|
|
34091
34177
|
const target = await service(api2, args.serviceKey, secrets, true);
|
package/docs/commands.md
CHANGED
|
@@ -29,11 +29,24 @@ Initialization uses the nearest `package.json` directory, or the current folder
|
|
|
29
29
|
| `localdeck web` | Runs the configured `web` service and its transitive prerequisites in the terminal. |
|
|
30
30
|
| `localdeck up` | Runs all configured services, starting each after its own prerequisites become ready. |
|
|
31
31
|
| `localdeck up frontend api` | Runs services matching any supplied name or tag, plus their prerequisites. |
|
|
32
|
-
| `localdeck up --profile app` | Runs
|
|
32
|
+
| `localdeck up --profile app` | Deprecated. Runs a pre-1.6 list-style profile, now converted to the tag `app`; use `localdeck up app`. |
|
|
33
33
|
| `localdeck run web` | Runs the configured `web` service; an alternative to `localdeck web`. |
|
|
34
34
|
| `localdeck run api -- node server.js` | Runs a one-off command without requiring a configuration. |
|
|
35
35
|
| `localdeck run api --port 4000 --upstream 8787 -- node server.js` | Reserves stable port 4000 and explicitly proxies to upstream port 8787. Omit these flags for automatic allocation and detection. |
|
|
36
36
|
|
|
37
|
+
Terminal runs use the profiles turned on for the project and print them first, for example `Profiles: staging`.
|
|
38
|
+
|
|
39
|
+
## Profiles
|
|
40
|
+
|
|
41
|
+
| Command | What it does |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `localdeck profile` | Lists the project's profiles, which services each changes, and which are on. Alias: `profiles`. |
|
|
44
|
+
| `localdeck profile on staging debug` | Turns on one or more profiles for this project on this computer. |
|
|
45
|
+
| `localdeck profile off debug` | Turns profiles off. |
|
|
46
|
+
| `localdeck profile clear` | Turns every profile off, so services run as defined in the base configuration. |
|
|
47
|
+
|
|
48
|
+
Profile commands never start, stop or restart services. Running services keep their settings until restarted. See [profiles](configuration.md#profiles).
|
|
49
|
+
|
|
37
50
|
`run` accepts `-p` for `--port`, `-u` for `--upstream`, and `--port=4000`/`--upstream=8787`. Ports must be integers from 1 to 65535. Put LocalDeck options before the application command; `--` separates the two. `localdeck run web --port 4000` overrides the configured web service's stable port for that terminal run.
|
|
38
51
|
|
|
39
52
|
`{port}` inside a configured command expands to the assigned **upstream** port in both terminal and dashboard launches. `$PORT` also contains that port. The stable port is held by LocalDeck, so avoid assigning it the same port your tool insists on using.
|
|
@@ -87,7 +100,7 @@ Normal startup handles clean URLs automatically where supported. Manual `forward
|
|
|
87
100
|
| `NO_COLOR` | Disables CLI colors. |
|
|
88
101
|
| `CI` | Suppresses interactive clean-URL preparation when set. |
|
|
89
102
|
|
|
90
|
-
Child services receive `PORT` (upstream), `LOCALDECK_PORT` (stable/published port), and `LOCALDECK_SERVICE` (service name). HTTP services also receive `LOCALDECK_URL`, using a stable numeric localhost URL. These names and other `LOCALDECK_*` names are reserved in configuration `env`; see [environment wiring](configuration.md#
|
|
103
|
+
Child services receive `PORT` (upstream), `LOCALDECK_PORT` (stable/published port), and `LOCALDECK_SERVICE` (service name). HTTP services also receive `LOCALDECK_URL`, using a stable numeric localhost URL. These names and other `LOCALDECK_*` names are reserved in configuration `env`; see [environment wiring](configuration.md#environment-wiring).
|
|
91
104
|
|
|
92
105
|
## MCP client connection
|
|
93
106
|
|
package/docs/configuration.md
CHANGED
|
@@ -47,22 +47,58 @@ For Docker Compose databases, set `upstream` to the host-published database port
|
|
|
47
47
|
|
|
48
48
|
Native database presets are `ready: "postgres"` (requires `pg_isready`) and `ready: "redis"` (requires `redis-cli`). The matching `serviceType` selects this check by default. Compose checks use tools inside the named container: `{ "type": "compose", "service": "redis", "engine": "redis" }`. Compose service names and engines are validated; the host still needs Docker. **Listening** means TCP connections are accepted; **ready** means the configured readiness contract passed. Custom HTTP/command checks also run for services with no dependents.
|
|
49
49
|
|
|
50
|
-
###
|
|
50
|
+
### Environment wiring
|
|
51
51
|
|
|
52
|
-
Use the
|
|
52
|
+
Use `env` with `{service:NAME:url}` or `{service:NAME:port}` to hand one service the address of another it starts after:
|
|
53
53
|
|
|
54
54
|
```json
|
|
55
55
|
{
|
|
56
56
|
"services": [
|
|
57
57
|
{ "name": "api", "run": "npm run api", "ready": "/health" },
|
|
58
|
-
{ "name": "web", "run": "npm run web", "after": "api", "env": { "PUBLIC_API_URL": "{service:api:url}" } }
|
|
59
|
-
|
|
58
|
+
{ "name": "web", "run": "npm run web", "after": "api", "env": { "PUBLIC_API_URL": "{service:api:url}" } }
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Variable names such as `PUBLIC_API_URL` must match what your framework actually reads; LocalDeck does not rewrite application code or `.env` files.
|
|
64
|
+
|
|
65
|
+
### Profiles
|
|
66
|
+
|
|
67
|
+
A profile changes how services run without renaming them. `web` stays `web`, with the same port, link and startup order, while a `staging` profile swaps its command and environment. With no profile on, services run exactly as defined above; that is your local setup.
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"services": [
|
|
72
|
+
{ "name": "api", "run": "npm run api", "env": { "LOG_LEVEL": "info" } },
|
|
73
|
+
{ "name": "web", "run": "npm run dev", "after": "api", "env": { "API_URL": "{service:api:url}" } }
|
|
60
74
|
],
|
|
61
|
-
"profiles": {
|
|
75
|
+
"profiles": {
|
|
76
|
+
"staging": {
|
|
77
|
+
"description": "Frontend talks to the staging API",
|
|
78
|
+
"services": {
|
|
79
|
+
"web": {
|
|
80
|
+
"run": "npm run dev:staging",
|
|
81
|
+
"env": { "API_URL": "https://api.staging.example.com" },
|
|
82
|
+
"secrets": ["STAGING_TOKEN"]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"debug": {
|
|
87
|
+
"description": "Attach the Node inspector to the API",
|
|
88
|
+
"services": { "api": { "env": { "NODE_OPTIONS": "--inspect", "LOG_LEVEL": "debug" } } }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
62
91
|
}
|
|
63
92
|
```
|
|
64
93
|
|
|
65
|
-
|
|
94
|
+
- A profile may change a service's `run`, `cwd`, `env` and `secrets`. Name, ports, `after`, readiness and service type always come from the base service, so links and startup order never change.
|
|
95
|
+
- `env` merges key by key: `debug` above keeps `api`'s other variables and replaces `LOG_LEVEL`. `secrets` are added to the base list. The same reserved-name rules apply as for base services.
|
|
96
|
+
- Several profiles can be on at once. They apply in the order they are listed in the file. When two of them set the same command, directory or variable, the one listed later wins, and the dashboard and CLI say so.
|
|
97
|
+
- Which profiles are on is stored **on this computer only**, in `~/.localdeck/projects.json` (or `$LOCALDECK_HOME`). It is never written to `.localdeck`, so turning on `staging` does not change the file for your teammates.
|
|
98
|
+
- Turn profiles on with `localdeck profile on staging`, the Profiles menu at the top of the project page, or the MCP `set_profiles` tool. Switching never starts, stops or restarts anything. A running service keeps its settings and is marked **Restart to apply** until you restart it.
|
|
99
|
+
- Profile names use lowercase letters, numbers, `-` and `_`. An unknown service, or a field a profile cannot change, is rejected when the file loads.
|
|
100
|
+
|
|
101
|
+
**Older list-style profiles.** Before 1.6, `"profiles": { "app": ["web"] }` named a group of services to start. That form still loads: each listed service is tagged `app`, so run the group with `localdeck up app`. `localdeck up --profile app` keeps working for now with a notice. A file cannot mix service lists and profile objects under `profiles`.
|
|
66
102
|
|
|
67
103
|
Keys are forgiving (`command`/`value`/`cmd` work too), and `{ "web": "npm run dev" }` shorthand is accepted. LocalDeck looks for `.localdeck`, `.localdeck.json` or `localdeck.json`, walking up from the current folder, so it works from anywhere inside a monorepo.
|
|
68
104
|
|