localdeck 1.5.0 → 1.6.2

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.
@@ -2977,6 +2977,7 @@ function writePrivateJson(file, value) {
2977
2977
  }
2978
2978
 
2979
2979
  // ../daemon/src/project-config.ts
2980
+ import { createHash } from "node:crypto";
2980
2981
  import fs2 from "node:fs";
2981
2982
  import path2 from "node:path";
2982
2983
  var DEFAULT_READY_TIMEOUT_MS = 60000;
@@ -3003,7 +3004,7 @@ function loadProject(from = process.cwd()) {
3003
3004
  function loadProjectFile(file) {
3004
3005
  const raw = readProjectJson(file);
3005
3006
  const commands = normalize(raw, file);
3006
- const profiles = parseProfiles(raw, commands, file);
3007
+ const { profiles, legacyProfiles } = parseProfiles(raw, commands, file);
3007
3008
  let nickname;
3008
3009
  if (raw && typeof raw === "object" && !Array.isArray(raw) && (Array.isArray(raw.commands) || Array.isArray(raw.services))) {
3009
3010
  const value = raw.nickname;
@@ -3013,7 +3014,7 @@ function loadProjectFile(file) {
3013
3014
  nickname = value.toLowerCase();
3014
3015
  }
3015
3016
  }
3016
- return { file, dir: path2.dirname(file), commands, ...profiles ? { profiles } : {}, ...nickname ? { nickname } : {} };
3017
+ return { file, dir: path2.dirname(file), commands, ...profiles ? { profiles } : {}, ...legacyProfiles ? { legacyProfiles } : {}, ...nickname ? { nickname } : {} };
3017
3018
  }
3018
3019
  function updateProjectNickname(file, nickname) {
3019
3020
  const raw = readProjectJson(file);
@@ -3174,35 +3175,8 @@ function normalize(raw, file) {
3174
3175
  if (serviceType && serviceType !== "http" && (entry.upstream == null || entry.port != null)) {
3175
3176
  throw new Error(`${path2.basename(file)}: database services need an explicit upstream (published port) and must not set an HTTP proxy port`);
3176
3177
  }
3177
- let env;
3178
- if (entry.env !== undefined) {
3179
- if (!entry.env || typeof entry.env !== "object" || Array.isArray(entry.env))
3180
- throw new Error(`${path2.basename(file)}: env must be an object of strings`);
3181
- env = {};
3182
- for (const [key, value2] of Object.entries(entry.env)) {
3183
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value2 !== "string" || value2.includes("\x00"))
3184
- throw new Error(`${path2.basename(file)}: env must contain valid variable names and string values`);
3185
- if (key === "PORT" || key.startsWith("LOCALDECK_"))
3186
- throw new Error(`${path2.basename(file)}: ${key} is reserved by LocalDeck`);
3187
- Object.defineProperty(env, key, { value: value2, enumerable: true, writable: true, configurable: true });
3188
- }
3189
- }
3190
- let secrets;
3191
- if (entry.secrets !== undefined) {
3192
- if (!Array.isArray(entry.secrets) || !entry.secrets.every((value2) => typeof value2 === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value2))) {
3193
- throw new Error(`${path2.basename(file)}: secrets must contain valid environment names`);
3194
- }
3195
- secrets = [];
3196
- for (const secret of entry.secrets) {
3197
- if (secret === "PORT" || secret.startsWith("LOCALDECK_"))
3198
- throw new Error(`${path2.basename(file)}: ${secret} is reserved by LocalDeck`);
3199
- if (env && Object.prototype.hasOwnProperty.call(env, secret))
3200
- throw new Error(`${path2.basename(file)}: ${secret} cannot be both a secret and env setting`);
3201
- if (secrets.includes(secret))
3202
- throw new Error(`${path2.basename(file)}: duplicate secret ${secret}`);
3203
- secrets.push(secret);
3204
- }
3205
- }
3178
+ const env = entry.env === undefined ? undefined : parseEnv(entry.env, file);
3179
+ const secrets = entry.secrets === undefined ? undefined : parseSecrets(entry.secrets, env, file);
3206
3180
  out.push({
3207
3181
  name,
3208
3182
  run,
@@ -3220,6 +3194,35 @@ function normalize(raw, file) {
3220
3194
  validateDependencies(out, file);
3221
3195
  return out;
3222
3196
  }
3197
+ function parseEnv(value, file) {
3198
+ if (!value || typeof value !== "object" || Array.isArray(value))
3199
+ throw new Error(`${path2.basename(file)}: env must be an object of strings`);
3200
+ const env = {};
3201
+ for (const [key, setting] of Object.entries(value)) {
3202
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof setting !== "string" || setting.includes("\x00"))
3203
+ throw new Error(`${path2.basename(file)}: env must contain valid variable names and string values`);
3204
+ if (key === "PORT" || key.startsWith("LOCALDECK_"))
3205
+ throw new Error(`${path2.basename(file)}: ${key} is reserved by LocalDeck`);
3206
+ Object.defineProperty(env, key, { value: setting, enumerable: true, writable: true, configurable: true });
3207
+ }
3208
+ return env;
3209
+ }
3210
+ function parseSecrets(value, env, file) {
3211
+ if (!Array.isArray(value) || !value.every((name) => typeof name === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name))) {
3212
+ throw new Error(`${path2.basename(file)}: secrets must contain valid environment names`);
3213
+ }
3214
+ const secrets = [];
3215
+ for (const secret of value) {
3216
+ if (secret === "PORT" || secret.startsWith("LOCALDECK_"))
3217
+ throw new Error(`${path2.basename(file)}: ${secret} is reserved by LocalDeck`);
3218
+ if (env && Object.prototype.hasOwnProperty.call(env, secret))
3219
+ throw new Error(`${path2.basename(file)}: ${secret} cannot be both a secret and env setting`);
3220
+ if (secrets.includes(secret))
3221
+ throw new Error(`${path2.basename(file)}: duplicate secret ${secret}`);
3222
+ secrets.push(secret);
3223
+ }
3224
+ return secrets;
3225
+ }
3223
3226
  function parseReadiness(value, file, index) {
3224
3227
  if (value === "postgres" || value === "redis") {
3225
3228
  return { type: "command", run: value === "postgres" ? "pg_isready -h 127.0.0.1 -p {upstream}" : "redis-cli -h 127.0.0.1 -p {upstream} ping", ...value === "redis" ? { expectedOutput: "PONG" } : {}, timeoutMs: DEFAULT_READY_TIMEOUT_MS, intervalMs: DEFAULT_READY_INTERVAL_MS };
@@ -3264,22 +3267,159 @@ function parseReadiness(value, file, index) {
3264
3267
  throw new Error(`${path2.basename(file)}: expectedOutput must be a string of at most 4000 characters`);
3265
3268
  return { type, run: ready.run, ...ready.expectedOutput === undefined ? {} : { expectedOutput: ready.expectedOutput }, timeoutMs, intervalMs };
3266
3269
  }
3270
+ var OVERRIDE_FIELDS = ["run", "cwd", "env", "secrets"];
3267
3271
  function parseProfiles(raw, commands, file) {
3268
3272
  if (!raw || typeof raw !== "object" || Array.isArray(raw) || !Array.isArray(raw.services) && !Array.isArray(raw.commands))
3269
- return;
3273
+ return {};
3270
3274
  const value = raw.profiles;
3271
3275
  if (value === undefined)
3272
- return;
3276
+ return {};
3277
+ const base = path2.basename(file);
3273
3278
  if (!value || typeof value !== "object" || Array.isArray(value))
3274
- throw new Error(`${path2.basename(file)}: profiles must map names to arrays of service names`);
3275
- const profiles = Object.create(null);
3276
- for (const [name, services] of Object.entries(value)) {
3277
- if (!/^[a-z0-9][a-z0-9_-]{0,40}$/.test(name) || !Array.isArray(services) || !services.length || !services.every((service) => typeof service === "string" && commands.some((command) => command.name === service.toLowerCase()))) {
3278
- throw new Error(`${path2.basename(file)}: profile ${name} must contain existing service names`);
3279
+ throw new Error(`${base}: profiles must map names to profile objects`);
3280
+ const entries = Object.entries(value);
3281
+ if (!entries.length)
3282
+ return {};
3283
+ for (const [name] of entries)
3284
+ if (!/^[a-z0-9][a-z0-9_-]{0,40}$/.test(name))
3285
+ throw new Error(`${base}: invalid profile name "${name}"`);
3286
+ const lists = entries.filter(([, profile]) => Array.isArray(profile));
3287
+ if (lists.length && lists.length !== entries.length)
3288
+ throw new Error(`${base}: profiles cannot mix service lists and profile objects; move service lists to tags`);
3289
+ const byName = new Map(commands.map((command) => [command.name, command]));
3290
+ if (lists.length) {
3291
+ const legacyProfiles = Object.create(null);
3292
+ for (const [name, services] of lists) {
3293
+ if (!services.length || !services.every((service) => typeof service === "string" && byName.has(service.toLowerCase()))) {
3294
+ throw new Error(`${base}: profile ${name} must contain existing service names`);
3295
+ }
3296
+ legacyProfiles[name] = [...new Set(services.map((service) => service.toLowerCase()))];
3297
+ for (const service of legacyProfiles[name]) {
3298
+ const command = byName.get(service);
3299
+ if (!command.tags.includes(name))
3300
+ command.tags.push(name);
3301
+ }
3302
+ }
3303
+ return { legacyProfiles };
3304
+ }
3305
+ const profiles = [];
3306
+ for (const [name, entry] of entries) {
3307
+ if (!entry || typeof entry !== "object")
3308
+ throw new Error(`${base}: profile ${name} must be an object`);
3309
+ const record = entry;
3310
+ const unknownKey = Object.keys(record).find((key) => key !== "description" && key !== "services");
3311
+ if (unknownKey)
3312
+ throw new Error(`${base}: profile ${name} has unknown field "${unknownKey}"; use "description" and "services"`);
3313
+ if (record.description !== undefined && (typeof record.description !== "string" || record.description.length > 200))
3314
+ throw new Error(`${base}: profile ${name} description must be a string of at most 200 characters`);
3315
+ const services = record.services ?? {};
3316
+ if (!services || typeof services !== "object" || Array.isArray(services))
3317
+ throw new Error(`${base}: profile ${name} services must map service names to overrides`);
3318
+ const overrides = Object.create(null);
3319
+ for (const [rawService, rawOverride] of Object.entries(services)) {
3320
+ const service = rawService.toLowerCase();
3321
+ const command = byName.get(service);
3322
+ if (!command)
3323
+ throw new Error(`${base}: profile ${name} changes unknown service "${rawService}"`);
3324
+ if (!rawOverride || typeof rawOverride !== "object" || Array.isArray(rawOverride))
3325
+ throw new Error(`${base}: profile ${name} override for ${service} must be an object`);
3326
+ const override = rawOverride;
3327
+ const field = Object.keys(override).find((key) => !OVERRIDE_FIELDS.includes(key));
3328
+ if (field)
3329
+ throw new Error(`${base}: profile ${name} cannot change ${service}.${field}; profiles can change run, cwd, env and secrets`);
3330
+ if (override.run !== undefined && (typeof override.run !== "string" || !override.run.trim()))
3331
+ throw new Error(`${base}: profile ${name} ${service}.run must be a non-empty string`);
3332
+ if (override.cwd !== undefined && typeof override.cwd !== "string")
3333
+ throw new Error(`${base}: profile ${name} ${service}.cwd must be a string`);
3334
+ const env = override.env === undefined ? undefined : parseEnv(override.env, file);
3335
+ const secrets = override.secrets === undefined ? undefined : parseSecrets(override.secrets, env, file);
3336
+ const parsed = {
3337
+ ...override.run === undefined ? {} : { run: override.run },
3338
+ ...override.cwd === undefined ? {} : { cwd: override.cwd },
3339
+ ...env ? { env } : {},
3340
+ ...secrets ? { secrets } : {}
3341
+ };
3342
+ checkEnvSecretOverlap(mergeOverride(command, parsed).command, `profile ${name}`, file);
3343
+ overrides[service] = parsed;
3279
3344
  }
3280
- profiles[name] = [...new Set(services.map((service) => service.toLowerCase()))];
3345
+ profiles.push({ name, ...record.description ? { description: record.description } : {}, services: overrides });
3281
3346
  }
3282
- return profiles;
3347
+ return { profiles };
3348
+ }
3349
+ function mergeOverride(command, override) {
3350
+ const env = override.env ? { ...command.env, ...override.env } : command.env;
3351
+ const secrets = override.secrets ? [...new Set([...command.secrets ?? [], ...override.secrets])] : command.secrets;
3352
+ return {
3353
+ command: {
3354
+ ...command,
3355
+ ...override.run === undefined ? {} : { run: override.run },
3356
+ ...override.cwd === undefined ? {} : { cwd: override.cwd },
3357
+ ...env ? { env } : {},
3358
+ ...secrets ? { secrets } : {}
3359
+ }
3360
+ };
3361
+ }
3362
+ function checkEnvSecretOverlap(command, source, file) {
3363
+ const overlap = command.secrets?.find((secret) => command.env && Object.prototype.hasOwnProperty.call(command.env, secret));
3364
+ if (overlap)
3365
+ throw new Error(`${path2.basename(file)}: ${source} makes ${overlap} both a secret and env setting for ${command.name}`);
3366
+ }
3367
+ function applyProfiles(project, active = []) {
3368
+ const wanted = new Set(active);
3369
+ const applied = (project.profiles ?? []).filter((profile) => wanted.has(profile.name));
3370
+ const provenance = Object.create(null);
3371
+ const setters = new Map;
3372
+ const commands = project.commands.map((base) => {
3373
+ const origin = {
3374
+ run: "base",
3375
+ cwd: "base",
3376
+ env: Object.fromEntries(Object.keys(base.env ?? {}).map((key) => [key, "base"])),
3377
+ secrets: Object.fromEntries((base.secrets ?? []).map((secret) => [secret, "base"]))
3378
+ };
3379
+ let command = base;
3380
+ for (const profile of applied) {
3381
+ const override = profile.services[base.name];
3382
+ if (!override)
3383
+ continue;
3384
+ const note = (field) => {
3385
+ const key = `${base.name}\x00${field}`;
3386
+ setters.set(key, [...setters.get(key) ?? [], profile.name]);
3387
+ };
3388
+ if (override.run !== undefined) {
3389
+ origin.run = profile.name;
3390
+ note("run");
3391
+ }
3392
+ if (override.cwd !== undefined) {
3393
+ origin.cwd = profile.name;
3394
+ note("cwd");
3395
+ }
3396
+ for (const key of Object.keys(override.env ?? {})) {
3397
+ origin.env[key] = profile.name;
3398
+ note(`env.${key}`);
3399
+ }
3400
+ for (const secret of override.secrets ?? [])
3401
+ if (!(secret in origin.secrets))
3402
+ origin.secrets[secret] = profile.name;
3403
+ command = mergeOverride(command, override).command;
3404
+ }
3405
+ if (command !== base)
3406
+ checkEnvSecretOverlap(command, `active profiles ${applied.map((profile) => profile.name).join(" + ")}`, project.file);
3407
+ provenance[base.name] = origin;
3408
+ return command;
3409
+ });
3410
+ const clashes = [];
3411
+ for (const [key, profiles] of setters) {
3412
+ if (profiles.length < 2)
3413
+ continue;
3414
+ const [service, field] = key.split("\x00");
3415
+ clashes.push({ service, field, profiles, winner: profiles[profiles.length - 1] });
3416
+ }
3417
+ return { commands, active: applied.map((profile) => profile.name), provenance, clashes };
3418
+ }
3419
+ function commandFingerprint(command) {
3420
+ const env = Object.entries(command.env ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
3421
+ const secrets = [...command.secrets ?? []].sort();
3422
+ return createHash("sha256").update(JSON.stringify([command.run, command.cwd ?? "", env, secrets])).digest("hex").slice(0, 16);
3283
3423
  }
3284
3424
  function parseBoundedInteger(value, fallback, min, max, label, file, index) {
3285
3425
  if (value === undefined)
@@ -3424,10 +3564,10 @@ import path8 from "node:path";
3424
3564
  // ../daemon/src/worktrees.ts
3425
3565
  import fs4 from "node:fs";
3426
3566
  import path4 from "node:path";
3427
- import { createHash as createHash2 } from "node:crypto";
3567
+ import { createHash as createHash3 } from "node:crypto";
3428
3568
 
3429
3569
  // ../daemon/src/projects.ts
3430
- import { createHash } from "node:crypto";
3570
+ import { createHash as createHash2 } from "node:crypto";
3431
3571
  import fs3 from "node:fs";
3432
3572
  import { execFileSync } from "node:child_process";
3433
3573
  import path3 from "node:path";
@@ -3559,19 +3699,40 @@ class ProjectRegistry {
3559
3699
  list() {
3560
3700
  return this.sorted().map((project) => this.toSummary(project));
3561
3701
  }
3562
- details(projectId) {
3702
+ details(projectId, profiles) {
3563
3703
  const project = this.require(projectId);
3704
+ let loaded;
3564
3705
  try {
3565
- const loaded = this.load(project);
3566
- return { ...project, nickname: this.nickname(projectId), available: true, commands: loaded.commands, ...loaded.profiles ? { profiles: loaded.profiles } : {} };
3706
+ loaded = this.load(project);
3567
3707
  } catch (error) {
3568
3708
  return { ...project, available: false, error: errorMessage(error), commands: [] };
3569
3709
  }
3710
+ const wanted = profiles ?? project.activeProfiles ?? [];
3711
+ const { activeProfiles: _stored, ...remembered } = project;
3712
+ const base = { ...remembered, nickname: this.nickname(projectId), available: true, ...loaded.legacyProfiles ? { legacyProfiles: loaded.legacyProfiles } : {} };
3713
+ const summaries = (active) => loaded.profiles?.length ? { profiles: loaded.profiles.map((profile) => ({ name: profile.name, ...profile.description ? { description: profile.description } : {}, services: Object.keys(profile.services), active: active.includes(profile.name) })) } : {};
3714
+ try {
3715
+ const resolved = applyProfiles(loaded, wanted);
3716
+ return {
3717
+ ...base,
3718
+ ...resolved.active.length ? { activeProfiles: resolved.active } : {},
3719
+ commands: resolved.commands,
3720
+ ...summaries(resolved.active),
3721
+ ...resolved.clashes.length ? { profileClashes: resolved.clashes } : {},
3722
+ provenance: resolved.provenance,
3723
+ fingerprints: Object.fromEntries(resolved.commands.map((command) => [command.name, commandFingerprint(command)]))
3724
+ };
3725
+ } catch (error) {
3726
+ const active = (loaded.profiles ?? []).map((profile) => profile.name).filter((name) => wanted.includes(name));
3727
+ return { ...base, ...active.length ? { activeProfiles: active } : {}, commands: loaded.commands, ...summaries(active), profileError: errorMessage(error) };
3728
+ }
3570
3729
  }
3571
- resolve(projectId, serviceName2) {
3572
- const details = this.details(projectId);
3730
+ resolve(projectId, serviceName2, profiles) {
3731
+ const details = this.details(projectId, profiles);
3573
3732
  if (!details.available)
3574
3733
  throw new ProjectError("invalid", details.error);
3734
+ if (details.profileError)
3735
+ throw new ProjectError("invalid", details.profileError);
3575
3736
  const command = details.commands.find((candidate) => candidate.name === serviceName2.toLowerCase());
3576
3737
  if (!command)
3577
3738
  throw new ProjectError("not_found", `Project ${projectId} has no service named ${serviceName2}`);
@@ -3632,6 +3793,37 @@ class ProjectRegistry {
3632
3793
  project.configNickname = nickname || undefined;
3633
3794
  return this.updateNickname(projectId, "");
3634
3795
  }
3796
+ setActiveProfiles(projectId, names) {
3797
+ const project = this.require(projectId);
3798
+ if (!Array.isArray(names) || !names.every((name) => typeof name === "string"))
3799
+ throw new ProjectError("invalid", "profiles must be an array of profile names");
3800
+ let defined;
3801
+ try {
3802
+ defined = (this.load(project).profiles ?? []).map((profile) => profile.name);
3803
+ } catch (error) {
3804
+ throw new ProjectError("invalid", errorMessage(error));
3805
+ }
3806
+ const requested = names.map((name) => name.toLowerCase());
3807
+ const unknown = requested.filter((name) => !defined.includes(name));
3808
+ if (unknown.length)
3809
+ throw new ProjectError("invalid", `Unknown profile${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")}. Available profiles: ${defined.join(", ") || "(none)"}`);
3810
+ const previous = project.activeProfiles;
3811
+ const active = defined.filter((name) => requested.includes(name));
3812
+ if (active.length)
3813
+ project.activeProfiles = active;
3814
+ else
3815
+ delete project.activeProfiles;
3816
+ try {
3817
+ this.persist();
3818
+ } catch (error) {
3819
+ if (previous === undefined)
3820
+ delete project.activeProfiles;
3821
+ else
3822
+ project.activeProfiles = previous;
3823
+ throw error;
3824
+ }
3825
+ return this.details(projectId);
3826
+ }
3635
3827
  updateDependencies(projectId, serviceName2, after) {
3636
3828
  const project = this.require(projectId);
3637
3829
  try {
@@ -3750,7 +3942,7 @@ class ProjectRegistry {
3750
3942
  }
3751
3943
  }
3752
3944
  function projectId(realFile) {
3753
- return createHash("sha256").update(realFile).digest("hex").slice(0, 16);
3945
+ return createHash2("sha256").update(realFile).digest("hex").slice(0, 16);
3754
3946
  }
3755
3947
  function slugify(value) {
3756
3948
  return value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
@@ -3768,7 +3960,7 @@ function isRememberedProject(value) {
3768
3960
  if (!["id", "name", "slug", "file", "directory"].every((key) => typeof project[key] === "string"))
3769
3961
  return false;
3770
3962
  const { id, slug, file, directory } = project;
3771
- return path3.isAbsolute(file) && path3.normalize(file) === file && directory === path3.dirname(file) && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(slug) && /^[a-f0-9]{16}$/.test(id) && (project.nickname === undefined || typeof project.nickname === "string" && validNickname(project.nickname)) && (project.configNickname === undefined || typeof project.configNickname === "string" && validNickname(project.configNickname)) && (project.sharedConfigNickname === undefined || typeof project.sharedConfigNickname === "boolean") && id === projectId(file) && typeof project.lastUsedAt === "number" && Number.isFinite(project.lastUsedAt);
3963
+ return path3.isAbsolute(file) && path3.normalize(file) === file && directory === path3.dirname(file) && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(slug) && /^[a-f0-9]{16}$/.test(id) && (project.nickname === undefined || typeof project.nickname === "string" && validNickname(project.nickname)) && (project.configNickname === undefined || typeof project.configNickname === "string" && validNickname(project.configNickname)) && (project.sharedConfigNickname === undefined || typeof project.sharedConfigNickname === "boolean") && (project.activeProfiles === undefined || Array.isArray(project.activeProfiles) && project.activeProfiles.every((name) => typeof name === "string" && /^[a-z0-9][a-z0-9_-]{0,40}$/.test(name))) && id === projectId(file) && typeof project.lastUsedAt === "number" && Number.isFinite(project.lastUsedAt);
3772
3964
  }
3773
3965
  function validNickname(value) {
3774
3966
  return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
@@ -3834,7 +4026,7 @@ function discoverWorktrees(projects, projectId2) {
3834
4026
  const known = registered.get(configFile);
3835
4027
  const branch = fields.find((field) => field.startsWith("branch "))?.slice(7).replace(/^refs\/heads\//, "");
3836
4028
  environments.push({
3837
- id: createHash2("sha256").update(configFile).digest("hex").slice(0, 16),
4029
+ id: createHash3("sha256").update(configFile).digest("hex").slice(0, 16),
3838
4030
  ...known ? { projectId: known.id } : {},
3839
4031
  directory,
3840
4032
  configFile,
@@ -6134,16 +6326,20 @@ async function handleApi(req, res, url, parts, deps) {
6134
6326
  const secretValues = Object.values((deps.resolveProjectSecrets ?? resolveProjectSecrets)(projectId2, names));
6135
6327
  return json(res, 200, { report: diagnosticReport(project, services, logs, deps.version, [deps.controlToken, ...secretValues]) });
6136
6328
  }
6137
- if (parts.length === 4 && parts[2] === "profiles" && method === "POST") {
6329
+ if (parts.length === 3 && parts[2] === "profiles" && method === "GET") {
6330
+ const details = requireRememberedProject(projects, projectId2);
6331
+ if (!details.available)
6332
+ throw new ProjectError("invalid", details.error ?? "project configuration is invalid");
6333
+ return json(res, 200, profilesResponse(details));
6334
+ }
6335
+ if (parts.length === 4 && parts[2] === "profiles" && parts[3] === "active" && method === "PUT") {
6138
6336
  requireMutationAllowed(deps);
6139
- await readBody(req);
6337
+ const body2 = await readBody(req);
6140
6338
  requireMutationAllowed(deps);
6141
- const project = projects.details(projectId2);
6142
- const names = project.profiles?.[parts[3]];
6143
- if (!project.available || !names)
6144
- throw new HttpError(422, "Unknown or unavailable startup profile");
6145
- const results = await Promise.all(names.map((name) => supervisor.start(projectId2, name)));
6146
- return json(res, 200, { ok: results.every((result) => result.ok), results });
6339
+ if (!body2 || typeof body2 !== "object" || Array.isArray(body2) || Object.keys(body2).some((key) => key !== "profiles"))
6340
+ throw new HttpError(400, 'request body must be { "profiles": [...] }');
6341
+ const details = projects.setActiveProfiles(projectId2, body2.profiles);
6342
+ return json(res, 200, profilesResponse(details));
6147
6343
  }
6148
6344
  if (parts.length === 2 && method === "GET") {
6149
6345
  const details = projects.details(projectId2);
@@ -6459,6 +6655,8 @@ function parseAttachMessage(raw) {
6459
6655
  if (input.upstreamSource !== undefined && input.upstreamSource !== "assigned" && input.upstreamSource !== "manual")
6460
6656
  throw new Error("upstreamSource must be assigned or manual");
6461
6657
  optionalPid(input.pid);
6658
+ if (input.profiles !== undefined && (!Array.isArray(input.profiles) || input.profiles.length > 64 || !input.profiles.every((name) => typeof name === "string" && /^[a-z0-9][a-z0-9_-]{0,40}$/.test(name))))
6659
+ throw new Error("profiles must be an array of profile names");
6462
6660
  return input;
6463
6661
  }
6464
6662
  if (input.type === "log") {
@@ -6541,10 +6739,10 @@ function buildRegistration(message, deps) {
6541
6739
  pid: message.pid
6542
6740
  };
6543
6741
  }
6544
- const project = deps.projects.details(requestedProject);
6742
+ const project = deps.projects.details(requestedProject, message.profiles);
6545
6743
  if (!project.available)
6546
6744
  throw new ProjectError("invalid", project.error ?? "project configuration is invalid");
6547
- const command = deps.projects.resolve(requestedProject, canonicalName);
6745
+ const command = deps.projects.resolve(requestedProject, canonicalName, message.profiles);
6548
6746
  if (command.serviceType && command.serviceType !== "http" && (message.upstreamPort !== command.upstream || message.stablePort !== undefined)) {
6549
6747
  throw new Error("database registration must use its configured published upstream port");
6550
6748
  }
@@ -6563,7 +6761,18 @@ function buildRegistration(message, deps) {
6563
6761
  upstreamSource: message.upstreamSource ?? (command.upstream === undefined ? "assigned" : "manual"),
6564
6762
  cwd,
6565
6763
  command: command.run,
6566
- pid: message.pid
6764
+ pid: message.pid,
6765
+ ...project.activeProfiles?.length ? { profiles: project.activeProfiles } : {},
6766
+ configFingerprint: commandFingerprint(command)
6767
+ };
6768
+ }
6769
+ function profilesResponse(details) {
6770
+ return {
6771
+ profiles: details.profiles ?? [],
6772
+ active: details.activeProfiles ?? [],
6773
+ clashes: details.profileClashes ?? [],
6774
+ ...details.profileError ? { error: details.profileError } : {},
6775
+ ...details.legacyProfiles ? { legacyProfiles: details.legacyProfiles } : {}
6567
6776
  };
6568
6777
  }
6569
6778
  function requireDirectory(cwd) {
@@ -6803,6 +7012,8 @@ class Registry {
6803
7012
  pid: opts.pid,
6804
7013
  cwd: opts.cwd,
6805
7014
  command: opts.command,
7015
+ ...opts.profiles?.length ? { profiles: [...opts.profiles] } : {},
7016
+ ...opts.configFingerprint ? { configFingerprint: opts.configFingerprint } : {},
6806
7017
  startedAt: Date.now(),
6807
7018
  upstreamReady: false,
6808
7019
  urls: [
@@ -7042,7 +7253,7 @@ import fs11 from "node:fs";
7042
7253
  // ../daemon/src/activity.ts
7043
7254
  import fs10 from "node:fs";
7044
7255
  import path11 from "node:path";
7045
- import { randomUUID as randomUUID3, createHash as createHash3 } from "node:crypto";
7256
+ import { randomUUID as randomUUID3, createHash as createHash4 } from "node:crypto";
7046
7257
 
7047
7258
  // ../daemon/src/history.ts
7048
7259
  function isAfterHistoryBoundary(id, boundary) {
@@ -7077,7 +7288,7 @@ function sanitizeSignature(value, secrets2) {
7077
7288
  return redactDiagnostic(querySafe, secrets2.flatMap((secret) => [secret, encodeURIComponent(secret)]));
7078
7289
  }
7079
7290
  function boundedSignature(value) {
7080
- return value.length <= 300 ? value : `${value.slice(0, 230)}… [${createHash3("sha256").update(value).digest("hex")}]`;
7291
+ return value.length <= 300 ? value : `${value.slice(0, 230)}… [${createHash4("sha256").update(value).digest("hex")}]`;
7081
7292
  }
7082
7293
  function validEvent(value) {
7083
7294
  if (!value || typeof value !== "object")
@@ -7227,7 +7438,7 @@ class Activity {
7227
7438
  throw new Error("Search must be at most 200 characters");
7228
7439
  const all = this.records(projectId2, services), coverage = this.coverage(services, all);
7229
7440
  let cursor;
7230
- const scope = createHash3("sha256").update([...new Set(services)].sort().join(",")).digest("hex");
7441
+ const scope = createHash4("sha256").update([...new Set(services)].sort().join(",")).digest("hex");
7231
7442
  if (options.cursor) {
7232
7443
  try {
7233
7444
  if (options.cursor.length > 4096)
@@ -7345,7 +7556,7 @@ class ResponseBodies {
7345
7556
 
7346
7557
  // ../daemon/src/store.ts
7347
7558
  import path12 from "node:path";
7348
- import { createHash as createHash4 } from "node:crypto";
7559
+ import { createHash as createHash5 } from "node:crypto";
7349
7560
  import { EventEmitter } from "node:events";
7350
7561
  import { finished } from "node:stream/promises";
7351
7562
  import { Writable } from "node:stream";
@@ -7436,7 +7647,7 @@ class Store extends EventEmitter {
7436
7647
  return `${this.daemonStartedAt.toString(36)}-${(++this.sequence).toString(36)}`;
7437
7648
  }
7438
7649
  compatibilityId(type, service, offset, serialized) {
7439
- const hash = createHash4("sha256").update(`${type}\x00${service}\x00${offset}\x00${serialized}`).digest("hex");
7650
+ const hash = createHash5("sha256").update(`${type}\x00${service}\x00${offset}\x00${serialized}`).digest("hex");
7440
7651
  return `legacy-${hash}`;
7441
7652
  }
7442
7653
  historyFile(service, type) {
@@ -8551,7 +8762,9 @@ class ProcessSupervisor {
8551
8762
  upstreamSource: command.upstream === undefined ? "assigned" : "manual",
8552
8763
  cwd,
8553
8764
  command: command.run,
8554
- serviceType: command.serviceType
8765
+ serviceType: command.serviceType,
8766
+ ...project.activeProfiles?.length ? { profiles: project.activeProfiles } : {},
8767
+ configFingerprint: commandFingerprint(command)
8555
8768
  }, () => void this.stop(key, "stop requested"));
8556
8769
  } catch (error) {
8557
8770
  this.releaseAutomaticUpstream(reservedUpstreamPort);
@@ -9028,7 +9241,7 @@ async function waitForProcessGroupExit(groupId, timeoutMs) {
9028
9241
  return true;
9029
9242
  }
9030
9243
  // ../daemon/package.json
9031
- var version = "1.5.0";
9244
+ var version = "1.6.2";
9032
9245
 
9033
9246
  // ../daemon/src/index.ts
9034
9247
  var VERSION = version;
@@ -9308,6 +9521,8 @@ class Api {
9308
9521
  shareInfo = (key) => this.call("GET", `/api/services/${encodeURIComponent(key)}/share`);
9309
9522
  replay = (key, requestId) => this.call("POST", `/api/services/${encodeURIComponent(key)}/replay`, { requestId });
9310
9523
  services = () => this.call("GET", "/api/services");
9524
+ profiles = (projectId2) => this.call("GET", `/api/projects/${encodeURIComponent(projectId2)}/profiles`);
9525
+ setActiveProfiles = (projectId2, profiles) => this.call("PUT", `/api/projects/${encodeURIComponent(projectId2)}/profiles/active`, { profiles });
9311
9526
  rememberProject = (file, token) => this.call("POST", "/api/projects/remember", { file }, { authorization: `Bearer ${token}` });
9312
9527
  async servicePath(reference) {
9313
9528
  const separator = reference.lastIndexOf("/");
@@ -9332,4 +9547,4 @@ class Api {
9332
9547
  shutdown = () => this.call("POST", "/api/shutdown");
9333
9548
  }
9334
9549
 
9335
- export { __toESM, __commonJS, __export, __require, wrapper_default, HOME_DIR, STATE_FILE, DEFAULT_API_PORT, assertUserRuntime, isPortFree, getFreePort, findProjectFile, loadProject, loadProjectFile, shellCommand, redactDiagnostic, environmentValues, listProjectSecrets, resolveProjectSecrets, startForwarder, serviceKey, processTree, listeningPorts, chooseUpstream, unreachableListenerMessage, connectHost, streamLines, killProcessTree, stripAnsi, startupGuidance, inferPortEnvironment, startupLayers, waitForReadiness, secretOutput, VERSION, startDaemon, findDaemon, dashboardDir, ensureDaemon, Api };
9550
+ export { __toESM, __commonJS, __export, __require, wrapper_default, HOME_DIR, STATE_FILE, DEFAULT_API_PORT, assertUserRuntime, isPortFree, getFreePort, findProjectFile, loadProject, loadProjectFile, applyProfiles, shellCommand, redactDiagnostic, environmentValues, listProjectSecrets, resolveProjectSecrets, startForwarder, serviceKey, processTree, listeningPorts, chooseUpstream, unreachableListenerMessage, connectHost, streamLines, killProcessTree, stripAnsi, startupGuidance, inferPortEnvironment, startupLayers, waitForReadiness, secretOutput, VERSION, startDaemon, findDaemon, dashboardDir, ensureDaemon, Api };