@woosau/dsh-custom-env 0.1.8 → 0.1.10

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/lib/index.js CHANGED
@@ -951,6 +951,171 @@ function removeEnvEntries(names) {
951
951
  return removed;
952
952
  }
953
953
 
954
+ // src/groups-store.ts
955
+ import { randomBytes } from "node:crypto";
956
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
957
+ import { dirname as dirname6, join as join8 } from "node:path";
958
+ import { resolveDshHome as resolveDshHome2 } from "@deepseek-ai/dsh-home-paths";
959
+
960
+ // src/group-ids.ts
961
+ var DEFAULT_GROUP_ID = "default";
962
+ var DEFAULT_GROUP_TITLE = "\u9ED8\u8BA4\u5206\u7EC4";
963
+
964
+ // src/groups-store.ts
965
+ function groupsFilePath() {
966
+ return join8(resolveDshHome2(), ".dsh-custom-env-groups.json");
967
+ }
968
+ function defaultFile() {
969
+ return {
970
+ version: 1,
971
+ groups: [{ id: DEFAULT_GROUP_ID, title: DEFAULT_GROUP_TITLE }],
972
+ membership: {}
973
+ };
974
+ }
975
+ function isRecord(value) {
976
+ return typeof value === "object" && value !== null && !Array.isArray(value);
977
+ }
978
+ function normalizeGroupsFile(raw) {
979
+ if (!isRecord(raw)) return defaultFile();
980
+ const groupsIn = Array.isArray(raw.groups) ? raw.groups : [];
981
+ const groups = [];
982
+ const seen = /* @__PURE__ */ new Set();
983
+ let hasDefault = false;
984
+ for (const item of groupsIn) {
985
+ if (!isRecord(item)) continue;
986
+ const id = typeof item.id === "string" ? item.id.trim() : "";
987
+ const title = typeof item.title === "string" ? item.title.trim() : "";
988
+ if (id === "" || title === "" || seen.has(id)) continue;
989
+ seen.add(id);
990
+ if (id === DEFAULT_GROUP_ID) {
991
+ hasDefault = true;
992
+ groups.unshift({ id: DEFAULT_GROUP_ID, title: DEFAULT_GROUP_TITLE });
993
+ continue;
994
+ }
995
+ groups.push({ id, title });
996
+ }
997
+ if (!hasDefault) groups.unshift({ id: DEFAULT_GROUP_ID, title: DEFAULT_GROUP_TITLE });
998
+ const groupIds = new Set(groups.map((group) => group.id));
999
+ const membership = {};
1000
+ if (isRecord(raw.membership)) {
1001
+ for (const [entryId, groupId] of Object.entries(raw.membership)) {
1002
+ if (typeof groupId !== "string" || entryId.trim() === "" || groupId.trim() === "") continue;
1003
+ membership[entryId] = groupIds.has(groupId) ? groupId : DEFAULT_GROUP_ID;
1004
+ }
1005
+ }
1006
+ return { version: 1, groups, membership };
1007
+ }
1008
+ function readGroupsFile() {
1009
+ const path = groupsFilePath();
1010
+ if (!existsSync8(path)) return defaultFile();
1011
+ try {
1012
+ return normalizeGroupsFile(JSON.parse(readFileSync3(path, "utf8")));
1013
+ } catch {
1014
+ return defaultFile();
1015
+ }
1016
+ }
1017
+ function writeGroupsFile(file) {
1018
+ const path = groupsFilePath();
1019
+ mkdirSync4(dirname6(path), { recursive: true });
1020
+ const normalized = normalizeGroupsFile(file);
1021
+ writeFileSync4(path, `${JSON.stringify(normalized, null, 2)}
1022
+ `, "utf8");
1023
+ }
1024
+ function pruneMembership(file, liveIds) {
1025
+ let changed = false;
1026
+ const membership = {};
1027
+ for (const [entryId, groupId] of Object.entries(file.membership)) {
1028
+ if (!liveIds.has(entryId)) {
1029
+ changed = true;
1030
+ continue;
1031
+ }
1032
+ membership[entryId] = groupId;
1033
+ }
1034
+ return changed ? { ...file, membership } : file;
1035
+ }
1036
+ function resolveGroupId(file, entryId) {
1037
+ const groupId = file.membership[entryId];
1038
+ if (groupId === void 0) return DEFAULT_GROUP_ID;
1039
+ return file.groups.some((group) => group.id === groupId) ? groupId : DEFAULT_GROUP_ID;
1040
+ }
1041
+ function setMembership(file, entryId, groupId) {
1042
+ const nextId = file.groups.some((group) => group.id === groupId) ? groupId : DEFAULT_GROUP_ID;
1043
+ if (file.membership[entryId] === nextId) return file;
1044
+ return { ...file, membership: { ...file.membership, [entryId]: nextId } };
1045
+ }
1046
+ function clearMembership(file, entryId) {
1047
+ if (file.membership[entryId] === void 0) return file;
1048
+ const membership = { ...file.membership };
1049
+ delete membership[entryId];
1050
+ return { ...file, membership };
1051
+ }
1052
+ function clearMembershipMany(file, entryIds) {
1053
+ let next = file;
1054
+ for (const entryId of entryIds) next = clearMembership(next, entryId);
1055
+ return next;
1056
+ }
1057
+ function allocGroupId(existing) {
1058
+ for (; ; ) {
1059
+ const id = `g-${randomBytes(6).toString("hex")}`;
1060
+ if (!existing.has(id)) return id;
1061
+ }
1062
+ }
1063
+ function createGroup(file, title) {
1064
+ const trimmed = title.trim();
1065
+ if (trimmed === "") throw new Error("empty group title");
1066
+ const existing = new Set(file.groups.map((group2) => group2.id));
1067
+ const group = { id: allocGroupId(existing), title: trimmed };
1068
+ return { file: { ...file, groups: [...file.groups, group] }, group };
1069
+ }
1070
+ function renameGroup(file, id, title) {
1071
+ if (id === DEFAULT_GROUP_ID) throw new Error("cannot rename default group");
1072
+ const trimmed = title.trim();
1073
+ if (trimmed === "") throw new Error("empty group title");
1074
+ const index = file.groups.findIndex((group) => group.id === id);
1075
+ if (index < 0) throw new Error(`unknown group "${id}"`);
1076
+ const groups = file.groups.map((group, i) => i === index ? { id, title: trimmed } : group);
1077
+ return { ...file, groups };
1078
+ }
1079
+ function deleteGroupMeta(file, id, moveToDefault) {
1080
+ if (id === DEFAULT_GROUP_ID) throw new Error("cannot delete default group");
1081
+ if (!file.groups.some((group) => group.id === id)) throw new Error(`unknown group "${id}"`);
1082
+ const memberIds = Object.entries(file.membership).filter(([, groupId]) => groupId === id).map(([entryId]) => entryId);
1083
+ const membership = { ...file.membership };
1084
+ for (const entryId of memberIds) {
1085
+ if (moveToDefault) membership[entryId] = DEFAULT_GROUP_ID;
1086
+ else delete membership[entryId];
1087
+ }
1088
+ return {
1089
+ file: {
1090
+ ...file,
1091
+ groups: file.groups.filter((group) => group.id !== id),
1092
+ membership
1093
+ },
1094
+ memberIds
1095
+ };
1096
+ }
1097
+ function hasGroup(file, groupId) {
1098
+ return file.groups.some((group) => group.id === groupId);
1099
+ }
1100
+
1101
+ // src/os-user-env/write-user-env-child.ts
1102
+ import { spawn as spawn2 } from "node:child_process";
1103
+ import { existsSync as existsSync9 } from "node:fs";
1104
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1105
+ function startWriteUserEnvChild(entries) {
1106
+ const scriptUrl = new URL("./write-user-env.child.js", import.meta.url);
1107
+ const script = fileURLToPath2(scriptUrl);
1108
+ if (!existsSync9(script)) return void 0;
1109
+ const child = spawn2(process.execPath, [script], {
1110
+ stdio: ["pipe", "ignore", "pipe"],
1111
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
1112
+ windowsHide: true
1113
+ });
1114
+ child.stdin?.end(JSON.stringify(entries), "utf8");
1115
+ child.stderr?.pipe(process.stderr);
1116
+ return child;
1117
+ }
1118
+
954
1119
  // src/types.ts
955
1120
  function classifyEntry(id, name, value, baselineById) {
956
1121
  const base = baselineById.get(id);
@@ -975,6 +1140,36 @@ function applyOsUserEnv(entries) {
975
1140
  `);
976
1141
  }
977
1142
  }
1143
+ function scheduleOsUserEnvSync(ctx, entries) {
1144
+ if (Object.keys(entries).length === 0) return;
1145
+ let child;
1146
+ const handle = setImmediate(() => {
1147
+ try {
1148
+ child = startWriteUserEnvChild(entries);
1149
+ if (child === void 0) {
1150
+ applyOsUserEnv(entries);
1151
+ return;
1152
+ }
1153
+ child.on("error", (error) => {
1154
+ process.stderr.write(
1155
+ `custom-env: OS user env child failed (${asMessage(error)}); falling back to sync write
1156
+ `
1157
+ );
1158
+ applyOsUserEnv(entries);
1159
+ });
1160
+ } catch (error) {
1161
+ process.stderr.write(
1162
+ `custom-env: could not start OS user env child (${asMessage(error)}); falling back to sync write
1163
+ `
1164
+ );
1165
+ applyOsUserEnv(entries);
1166
+ }
1167
+ });
1168
+ ctx.effect(() => () => {
1169
+ clearImmediate(handle);
1170
+ child?.kill();
1171
+ }, "custom-env: deferred OS env sync");
1172
+ }
978
1173
  function writeLive(entries) {
979
1174
  applyProcessEnv(entries);
980
1175
  applyOsUserEnv(entries);
@@ -994,8 +1189,8 @@ function removeLives(names) {
994
1189
  function removeLive(name) {
995
1190
  removeLives([name]);
996
1191
  }
997
- var _cliInstall_dec, _cliStatus_dec, _restore_dec, _deleteEntry_dec, _removeVar_dec, _removeVars_dec, _setVar_dec, _setVars_dec, _upsert_dec, _list_dec, _a, _init;
998
- var CustomEnvController = class extends (_a = TypertRemoteService, _list_dec = [Remote("list")], _upsert_dec = [Remote("upsert")], _setVars_dec = [Remote("setVars")], _setVar_dec = [Remote("setVar")], _removeVars_dec = [Remote("removeVars")], _removeVar_dec = [Remote("removeVar")], _deleteEntry_dec = [Remote("deleteEntry")], _restore_dec = [Remote("restore")], _cliStatus_dec = [Remote("cliStatus")], _cliInstall_dec = [Remote("cliInstall")], _a) {
1192
+ var _cliInstall_dec, _cliStatus_dec, _moveEntry_dec, _deleteGroup_dec, _renameGroup_dec, _createGroup_dec, _restore_dec, _deleteEntry_dec, _removeVar_dec, _removeVars_dec, _setVar_dec, _setVars_dec, _upsert_dec, _list_dec, _a, _init;
1193
+ var CustomEnvController = class extends (_a = TypertRemoteService, _list_dec = [Remote("list")], _upsert_dec = [Remote("upsert")], _setVars_dec = [Remote("setVars")], _setVar_dec = [Remote("setVar")], _removeVars_dec = [Remote("removeVars")], _removeVar_dec = [Remote("removeVar")], _deleteEntry_dec = [Remote("deleteEntry")], _restore_dec = [Remote("restore")], _createGroup_dec = [Remote("createGroup")], _renameGroup_dec = [Remote("renameGroup")], _deleteGroup_dec = [Remote("deleteGroup")], _moveEntry_dec = [Remote("moveEntry")], _cliStatus_dec = [Remote("cliStatus")], _cliInstall_dec = [Remote("cliInstall")], _a) {
999
1194
  constructor(ctx) {
1000
1195
  super(ctx, "customEnv");
1001
1196
  __runInitializers(_init, 5, this);
@@ -1010,14 +1205,7 @@ var CustomEnvController = class extends (_a = TypertRemoteService, _list_dec = [
1010
1205
  this.baselineById.set(id, { name, value });
1011
1206
  }
1012
1207
  applyProcessEnv(loaded);
1013
- if (Object.keys(loaded).length > 0) {
1014
- const handle = setImmediate(() => {
1015
- applyOsUserEnv(loaded);
1016
- });
1017
- ctx.effect(() => () => {
1018
- clearImmediate(handle);
1019
- }, "custom-env: deferred OS env sync");
1020
- }
1208
+ scheduleOsUserEnvSync(ctx, loaded);
1021
1209
  }
1022
1210
  allocId() {
1023
1211
  return `env-${++this.idSeq}`;
@@ -1040,6 +1228,19 @@ var CustomEnvController = class extends (_a = TypertRemoteService, _list_dec = [
1040
1228
  }
1041
1229
  return name;
1042
1230
  }
1231
+ loadGroups() {
1232
+ return readGroupsFile();
1233
+ }
1234
+ saveGroups(file) {
1235
+ writeGroupsFile(file);
1236
+ }
1237
+ requireGroup(file, groupId) {
1238
+ const id = groupId.trim() === "" ? DEFAULT_GROUP_ID : groupId;
1239
+ if (!hasGroup(file, id)) {
1240
+ throw new RemoteError("custom-env/invalid-group", `unknown group "${id}"`, { group: id });
1241
+ }
1242
+ return id;
1243
+ }
1043
1244
  /**
1044
1245
  * Write `name=value` replacing `from` in place; sync process.env / OS.
1045
1246
  * Empty values create missing keys only, unless `forceEmpty` (restore).
@@ -1066,6 +1267,7 @@ var CustomEnvController = class extends (_a = TypertRemoteService, _list_dec = [
1066
1267
  for (const [name, id] of [...this.idByName]) {
1067
1268
  if (!present.has(name)) this.unbind(id);
1068
1269
  }
1270
+ let groupsFile = this.loadGroups();
1069
1271
  const entries = raw.map(({ name, value }) => {
1070
1272
  let id = this.idByName.get(name);
1071
1273
  if (id === void 0) {
@@ -1073,31 +1275,58 @@ var CustomEnvController = class extends (_a = TypertRemoteService, _list_dec = [
1073
1275
  this.bind(id, name);
1074
1276
  }
1075
1277
  const classified = classifyEntry(id, name, value, this.baselineById);
1076
- return { id, name, value, kind: classified.kind, previous: classified.previous };
1278
+ const groupId = resolveGroupId(groupsFile, id);
1279
+ return { id, name, value, groupId, kind: classified.kind, previous: classified.previous };
1077
1280
  });
1078
- return { path, exists, entries };
1281
+ const liveIds = new Set(entries.map((entry) => entry.id));
1282
+ const pruned = pruneMembership(groupsFile, liveIds);
1283
+ if (pruned !== groupsFile) {
1284
+ this.saveGroups(pruned);
1285
+ groupsFile = pruned;
1286
+ }
1287
+ return { path, exists, groups: groupsFile.groups, entries };
1079
1288
  }
1080
- async upsert(id, name, value) {
1289
+ async upsert(id, name, value, groupId) {
1081
1290
  if (id === "") {
1291
+ let groupsFile = this.loadGroups();
1292
+ const target = this.requireGroup(groupsFile, groupId ?? DEFAULT_GROUP_ID);
1082
1293
  this.commit(name, name, value);
1083
- if (!this.idByName.has(name)) this.bind(this.allocId(), name);
1294
+ let rowId = this.idByName.get(name);
1295
+ if (rowId === void 0) {
1296
+ rowId = this.allocId();
1297
+ this.bind(rowId, name);
1298
+ }
1299
+ groupsFile = setMembership(groupsFile, rowId, target);
1300
+ this.saveGroups(groupsFile);
1084
1301
  return this.list();
1085
1302
  }
1086
1303
  const from = this.requireName(id, name);
1087
1304
  this.commit(from, name, value);
1088
1305
  this.bind(id, name);
1306
+ if (groupId !== void 0) {
1307
+ let groupsFile = this.loadGroups();
1308
+ const target = this.requireGroup(groupsFile, groupId);
1309
+ groupsFile = setMembership(groupsFile, id, target);
1310
+ this.saveGroups(groupsFile);
1311
+ }
1089
1312
  return this.list();
1090
1313
  }
1091
1314
  async setVars(entries) {
1092
1315
  if (entries.length === 0) return;
1316
+ let groupsFile = this.loadGroups();
1093
1317
  const lastByName = /* @__PURE__ */ new Map();
1094
1318
  for (const entry of entries) {
1095
1319
  if (!isWritableEnvName(entry.name)) {
1096
1320
  throw new RemoteError("custom-env/invalid-name", `refused name "${entry.name}"`, { name: entry.name });
1097
1321
  }
1098
- lastByName.set(entry.name, entry.value);
1322
+ if (entry.group !== void 0) {
1323
+ const group = this.requireGroup(groupsFile, entry.group);
1324
+ lastByName.set(entry.name, { value: entry.value, group });
1325
+ } else {
1326
+ lastByName.set(entry.name, { value: entry.value });
1327
+ }
1099
1328
  }
1100
- const normalized = [...lastByName].map(([name, value]) => ({ name, value }));
1329
+ const normalized = [...lastByName].map(([name, { value }]) => ({ name, value }));
1101
1330
  let written;
1102
1331
  try {
1103
1332
  written = writeEnvEntries(normalized);
@@ -1107,14 +1336,29 @@ var CustomEnvController = class extends (_a = TypertRemoteService, _list_dec = [
1107
1336
  }
1108
1337
  if (written.length === 0) return;
1109
1338
  const live = {};
1339
+ let membershipChanged = false;
1110
1340
  for (const entry of written) {
1111
1341
  live[entry.name] = entry.value;
1112
- if (!this.idByName.has(entry.name)) this.bind(this.allocId(), entry.name);
1342
+ let rowId = this.idByName.get(entry.name);
1343
+ const isNewRow = rowId === void 0;
1344
+ if (rowId === void 0) {
1345
+ rowId = this.allocId();
1346
+ this.bind(rowId, entry.name);
1347
+ }
1348
+ const explicitGroup = lastByName.get(entry.name)?.group;
1349
+ if (explicitGroup !== void 0) {
1350
+ groupsFile = setMembership(groupsFile, rowId, explicitGroup);
1351
+ membershipChanged = true;
1352
+ } else if (isNewRow) {
1353
+ groupsFile = setMembership(groupsFile, rowId, DEFAULT_GROUP_ID);
1354
+ membershipChanged = true;
1355
+ }
1113
1356
  }
1357
+ if (membershipChanged) this.saveGroups(groupsFile);
1114
1358
  writeLive(live);
1115
1359
  }
1116
- async setVar(name, value) {
1117
- await this.setVars([{ name, value }]);
1360
+ async setVar(name, value, group) {
1361
+ await this.setVars([{ name, value, group }]);
1118
1362
  }
1119
1363
  async removeVars(names) {
1120
1364
  if (names.length === 0) return 0;
@@ -1128,6 +1372,11 @@ var CustomEnvController = class extends (_a = TypertRemoteService, _list_dec = [
1128
1372
  seen.add(name);
1129
1373
  unique.push(name);
1130
1374
  }
1375
+ const dropIds = [];
1376
+ for (const name of unique) {
1377
+ const id = this.idByName.get(name);
1378
+ if (id !== void 0) dropIds.push(id);
1379
+ }
1131
1380
  let removed;
1132
1381
  try {
1133
1382
  removed = removeEnvEntries(unique);
@@ -1140,6 +1389,9 @@ var CustomEnvController = class extends (_a = TypertRemoteService, _list_dec = [
1140
1389
  const id = this.idByName.get(name);
1141
1390
  if (id !== void 0) this.unbind(id);
1142
1391
  }
1392
+ if (dropIds.length > 0) {
1393
+ this.saveGroups(clearMembershipMany(this.loadGroups(), dropIds));
1394
+ }
1143
1395
  return removed;
1144
1396
  }
1145
1397
  async removeVar(name) {
@@ -1169,6 +1421,66 @@ var CustomEnvController = class extends (_a = TypertRemoteService, _list_dec = [
1169
1421
  this.bind(id, base.name);
1170
1422
  return this.list();
1171
1423
  }
1424
+ async createGroup(title) {
1425
+ try {
1426
+ const { file, group } = createGroup(this.loadGroups(), title);
1427
+ this.saveGroups(file);
1428
+ return group;
1429
+ } catch (error) {
1430
+ throw new RemoteError("custom-env/invalid-group", asMessage(error), { group: title }, { cause: error });
1431
+ }
1432
+ }
1433
+ async renameGroup(id, title) {
1434
+ try {
1435
+ const file = renameGroup(this.loadGroups(), id, title);
1436
+ this.saveGroups(file);
1437
+ const group = file.groups.find((item) => item.id === id);
1438
+ if (group === void 0) {
1439
+ throw new RemoteError("custom-env/invalid-group", `unknown group "${id}"`, { group: id });
1440
+ }
1441
+ return group;
1442
+ } catch (error) {
1443
+ if (error instanceof RemoteError) throw error;
1444
+ throw new RemoteError("custom-env/invalid-group", asMessage(error), { group: id }, { cause: error });
1445
+ }
1446
+ }
1447
+ async deleteGroup(id, options) {
1448
+ let meta;
1449
+ try {
1450
+ meta = deleteGroupMeta(this.loadGroups(), id, options.moveToDefault);
1451
+ } catch (error) {
1452
+ throw new RemoteError("custom-env/invalid-group", asMessage(error), { group: id }, { cause: error });
1453
+ }
1454
+ if (!options.moveToDefault && meta.memberIds.length > 0) {
1455
+ const names = [];
1456
+ for (const entryId of meta.memberIds) {
1457
+ const name = this.nameById.get(entryId);
1458
+ if (name !== void 0) names.push(name);
1459
+ }
1460
+ if (names.length > 0) {
1461
+ try {
1462
+ removeEnvEntries(names);
1463
+ } catch (error) {
1464
+ throw new RemoteError("custom-env/invalid-name", asMessage(error), { name: names[0] }, { cause: error });
1465
+ }
1466
+ removeLives(names);
1467
+ for (const name of names) {
1468
+ const entryId = this.idByName.get(name);
1469
+ if (entryId !== void 0) this.unbind(entryId);
1470
+ }
1471
+ }
1472
+ }
1473
+ this.saveGroups(meta.file);
1474
+ return this.list();
1475
+ }
1476
+ async moveEntry(id, groupId) {
1477
+ this.requireName(id, id);
1478
+ let groupsFile = this.loadGroups();
1479
+ const target = this.requireGroup(groupsFile, groupId);
1480
+ groupsFile = setMembership(groupsFile, id, target);
1481
+ this.saveGroups(groupsFile);
1482
+ return this.list();
1483
+ }
1172
1484
  async cliStatus() {
1173
1485
  return cliStatus();
1174
1486
  }
@@ -1190,10 +1502,16 @@ __decorateElement(_init, 1, "removeVars", _removeVars_dec, CustomEnvController);
1190
1502
  __decorateElement(_init, 1, "removeVar", _removeVar_dec, CustomEnvController);
1191
1503
  __decorateElement(_init, 1, "deleteEntry", _deleteEntry_dec, CustomEnvController);
1192
1504
  __decorateElement(_init, 1, "restore", _restore_dec, CustomEnvController);
1505
+ __decorateElement(_init, 1, "createGroup", _createGroup_dec, CustomEnvController);
1506
+ __decorateElement(_init, 1, "renameGroup", _renameGroup_dec, CustomEnvController);
1507
+ __decorateElement(_init, 1, "deleteGroup", _deleteGroup_dec, CustomEnvController);
1508
+ __decorateElement(_init, 1, "moveEntry", _moveEntry_dec, CustomEnvController);
1193
1509
  __decorateElement(_init, 1, "cliStatus", _cliStatus_dec, CustomEnvController);
1194
1510
  __decorateElement(_init, 1, "cliInstall", _cliInstall_dec, CustomEnvController);
1195
1511
  __decoratorMetadata(_init, CustomEnvController);
1196
1512
  export {
1513
+ DEFAULT_GROUP_ID,
1514
+ DEFAULT_GROUP_TITLE,
1197
1515
  classifyEntry,
1198
1516
  CustomEnvController as default,
1199
1517
  envFilePath