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/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-316fp4p2.js";
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 up --profile <name> run a saved profile and its prerequisites
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-w6vc3sdy.js")).updateLocalDeck();
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-h8gf5pgj.js")).runMcp(rest);
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 profiles = project.profiles;
1037
- if (profile && (!profiles || !Object.prototype.hasOwnProperty.call(profiles, profile)))
1038
- fail(`unknown profile "${profile}". Available profiles: ${Object.keys(profiles ?? {}).join(", ") || "(none)"}`);
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(profiles[profile]);
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
- await dependencies.runForeground(selected.map((p, i) => toRunOptions(p, project, remembered, { prefix: selected.length > 1, color: PALETTE[i % PALETTE.length] })));
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-316fp4p2.js";
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
- items.set(serviceKey, { key: serviceKey, projectId: summary.id, projectName: summary.name, name: c.name, status: "stopped", managedBy: "daemon", configured: true, ...items.get(serviceKey) });
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
- return { data: { service: target.runtime ?? { key: args.serviceKey, projectId: target.projectId, name: target.name, status: "stopped", configured: true, managedBy: "daemon" }, notice } };
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);
@@ -3,7 +3,7 @@ import {
3
3
  STATE_FILE,
4
4
  assertUserRuntime,
5
5
  findDaemon
6
- } from "./index-316fp4p2.js";
6
+ } from "./index-2zg5ahh5.js";
7
7
 
8
8
  // src/update.ts
9
9
  import { spawn } from "node:child_process";
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 the names in the saved `app` profile and their prerequisites. Additional name/tag selectors must stay within the profile. |
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#profiles-and-environment-wiring).
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
 
@@ -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
- ### Profiles and environment wiring
50
+ ### Environment wiring
51
51
 
52
- Use the object format when defining startup profiles:
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
- { "name": "docs", "run": "npm run docs" }
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": { "app": ["web"], "everything": ["web", "docs"] }
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
- `localdeck up --profile app` starts web and its API prerequisite. Profiles are also selectable in the dashboard. Variable names such as `PUBLIC_API_URL` must match what your framework actually reads; LocalDeck does not rewrite application code or `.env` files.
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