amalgm 0.1.137 → 0.1.138

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.
@@ -9,6 +9,7 @@ const { hydrateConfigSkills } = require('../skills/store');
9
9
  const { defaultToolboxService } = require('../toolbox/service');
10
10
  const { exportAutomationEntry, installAutomationEntry } = require('./automation-entries');
11
11
  const { exportAppEntry, installAppEntry } = require('./app-entries');
12
+ const { annotateAppBoundPaths, rewriteAppBoundPaths } = require('./tool-bindings');
12
13
 
13
14
  // Agent-only bundles keep the legacy kind so older runtimes can still install
14
15
  // them; bundles that carry automations or apps use the general kind so older
@@ -157,28 +158,7 @@ function scrubSecrets(value, path, requires) {
157
158
  return out;
158
159
  }
159
160
 
160
- function sourceBindingRequirements(tool, requires) {
161
- const source = isObject(tool?.source) ? tool.source : {};
162
- if (typeof source.cwd === 'string' && source.cwd.trim()) {
163
- addUnique(requires.bindings, `tool:${tool.id}:cwd`);
164
- }
165
- for (const key of ['command', 'url', 'baseUrl', 'specUrl', 'specPath']) {
166
- const value = source[key];
167
- if (typeof value === 'string' && value.startsWith('/')) {
168
- addUnique(requires.bindings, `tool:${tool.id}:source.${key}`);
169
- }
170
- }
171
- if (Array.isArray(source.args)) {
172
- source.args.forEach((arg, index) => {
173
- if (typeof arg === 'string' && arg.startsWith('/')) {
174
- addUnique(requires.bindings, `tool:${tool.id}:source.args[${index}]`);
175
- }
176
- });
177
- }
178
- }
179
-
180
161
  function portableTool(tool, requires) {
181
- sourceBindingRequirements(tool, requires);
182
162
  return scrubSecrets(cloneJson(tool), `tools.${tool.id}`, requires);
183
163
  }
184
164
 
@@ -626,14 +606,26 @@ function createBundle(input = {}, options = {}) {
626
606
  }
627
607
 
628
608
  const apps = [];
609
+ const appCwdBySourceId = new Map();
629
610
  let skippedAppFiles = 0;
630
611
  for (const appId of appIds) {
631
612
  const exported = exportAppEntry(appId);
632
613
  apps.push(exported.entry);
614
+ appCwdBySourceId.set(exported.entry.sourceAppId, exported.sourceCwd);
633
615
  skippedAppFiles += exported.skipped.length;
634
616
  }
635
617
 
636
618
  const { tools, toolActions } = collectToolRecords([...loadoutToolIds, ...toolIds], requires);
619
+
620
+ // Tool files that live inside a shared app travel with that app: record
621
+ // app-relative bindings so install rewrites them to the recipient's copy.
622
+ // Absolute paths no shared app covers stay machine-bound requirements.
623
+ for (const record of [...tools, ...toolActions]) {
624
+ if (record.origin === 'system') continue;
625
+ const kind = record.toolId ? 'toolAction' : 'tool';
626
+ const { unbound } = annotateAppBoundPaths(record, appCwdBySourceId);
627
+ for (const label of unbound) addUnique(requires.bindings, `${kind}:${record.id}:${label}`);
628
+ }
637
629
  // Standalone tool heads must resolve to a real catalog tool — a dependency
638
630
  // tool that is missing degrades to a placeholder, but a head cannot.
639
631
  const sharedToolIds = toolIds.filter((toolId) => tools.some((tool) => tool.id === toolId));
@@ -816,12 +808,19 @@ function validateBundle(bundle) {
816
808
  return withBundleGraph(bundle);
817
809
  }
818
810
 
819
- function installTools(bundle) {
811
+ function installTools(bundle, appDirBySourceId = new Map()) {
812
+ const warnings = [];
813
+ const rewrite = (record) => {
814
+ const result = rewriteAppBoundPaths(record, appDirBySourceId);
815
+ warnings.push(...result.warnings);
816
+ return result.record;
817
+ };
818
+
820
819
  const toolActionsByToolId = new Map();
821
820
  for (const action of Array.isArray(bundle.toolActions) ? bundle.toolActions : []) {
822
821
  if (!action?.toolId) continue;
823
822
  const existing = toolActionsByToolId.get(action.toolId) || [];
824
- existing.push(action);
823
+ existing.push(rewrite(action));
825
824
  toolActionsByToolId.set(action.toolId, existing);
826
825
  }
827
826
 
@@ -829,13 +828,13 @@ function installTools(bundle) {
829
828
  for (const tool of Array.isArray(bundle.tools) ? bundle.tools : []) {
830
829
  if (!tool?.id || tool.origin === 'system') continue;
831
830
  const result = defaultToolboxService.registerTool({
832
- ...tool,
831
+ ...rewrite(tool),
833
832
  origin: tool.origin === 'catalog' ? 'catalog' : 'user',
834
833
  actions: toolActionsByToolId.get(tool.id) || [],
835
834
  });
836
835
  installed.push(result.tool);
837
836
  }
838
- return installed;
837
+ return { installed, warnings };
839
838
  }
840
839
 
841
840
  function installBundleAgents(bundle, options = {}) {
@@ -897,34 +896,40 @@ function installBundleAgents(bundle, options = {}) {
897
896
  }
898
897
 
899
898
  function defaultAppsInstallRoot() {
900
- const { getWorkspaceRoot } = require('../workspace/rest');
901
- return getWorkspaceRoot();
899
+ // Installed apps are Amalgm-owned state: they live under the user's apps
900
+ // primitive root (<user dir>/apps), not in the projects workspace.
901
+ return require('../config').APPS_DIR;
902
902
  }
903
903
 
904
904
  /**
905
- * Install every primitive in a validated bundle: tools first (agents and
906
- * automations reference them), then agents, automations, and apps. Apps
907
- * materialize into the local workspace root, build, and start.
905
+ * Install every primitive in a validated bundle. Apps first they
906
+ * materialize into the user's apps root, build, and start, and their
907
+ * installed directories anchor the app-bound tool path rewrites. Then tools
908
+ * (agents and automations reference them), agents, and automations.
908
909
  */
909
910
  async function installBundle(input, options = {}) {
910
911
  const bundle = validateBundle(input);
911
- const installedTools = installTools(bundle);
912
- const { idMap, installedAgents } = installBundleAgents(bundle, options);
913
-
914
- const installedAutomations = [];
915
- for (const entry of Array.isArray(bundle.automations) ? bundle.automations : []) {
916
- installedAutomations.push(installAutomationEntry(entry, { source: 'agent-bundles:install' }));
917
- }
918
912
 
919
913
  const installedApps = [];
914
+ const appDirBySourceId = new Map();
920
915
  const appEntries = Array.isArray(bundle.apps) ? bundle.apps : [];
921
916
  if (appEntries.length > 0) {
922
917
  const rootDir = options.appsRootDir || defaultAppsInstallRoot();
923
918
  for (const entry of appEntries) {
924
- installedApps.push(await installAppEntry(entry, { rootDir, register: options.registerApp }));
919
+ const installed = await installAppEntry(entry, { rootDir, register: options.registerApp });
920
+ appDirBySourceId.set(appEntryId(entry), installed.cwd);
921
+ installedApps.push(installed);
925
922
  }
926
923
  }
927
924
 
925
+ const { installed: installedTools, warnings: bindingWarnings } = installTools(bundle, appDirBySourceId);
926
+ const { idMap, installedAgents } = installBundleAgents(bundle, options);
927
+
928
+ const installedAutomations = [];
929
+ for (const entry of Array.isArray(bundle.automations) ? bundle.automations : []) {
930
+ installedAutomations.push(installAutomationEntry(entry, { source: 'agent-bundles:install' }));
931
+ }
932
+
928
933
  return {
929
934
  ok: true,
930
935
  bundleId: bundle.bundleId,
@@ -934,7 +939,7 @@ async function installBundle(input, options = {}) {
934
939
  installedTools,
935
940
  installedAutomations,
936
941
  installedApps,
937
- warnings: bundle.warnings || [],
942
+ warnings: [...(bundle.warnings || []), ...bindingWarnings],
938
943
  };
939
944
  }
940
945
 
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Portable bindings between toolbox tools and the apps that carry their files.
5
+ *
6
+ * A local CLI tool is only shareable if its files travel with the bundle. When
7
+ * a tool's cwd, command args, or guide/skill paths live inside a shared app's
8
+ * directory, export records app-relative bindings instead of trusting the
9
+ * sharer's absolute paths; install rewrites the bound fields against the
10
+ * freshly materialized app directory. Absolute paths no shared app covers are
11
+ * reported as binding requirements — the recipient must rebind those by hand.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const BINDINGS_KEY = 'appPathBindings';
18
+
19
+ function isObject(value) {
20
+ return !!value && typeof value === 'object' && !Array.isArray(value);
21
+ }
22
+
23
+ /** '' when target equals rootDir, a relative path when inside, null outside. */
24
+ function relativeWithin(rootDir, target) {
25
+ const relative = path.relative(canonicalFsPath(rootDir), canonicalFsPath(target));
26
+ if (relative === '') return '';
27
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
28
+ return relative;
29
+ }
30
+
31
+ function canonicalFsPath(value) {
32
+ try {
33
+ return fs.realpathSync.native(path.resolve(value));
34
+ } catch {
35
+ return path.resolve(value);
36
+ }
37
+ }
38
+
39
+ function getAtAddress(record, address) {
40
+ let current = record;
41
+ for (const step of address) {
42
+ if (current == null) return undefined;
43
+ current = current[step];
44
+ }
45
+ return current;
46
+ }
47
+
48
+ function setAtAddress(record, address, value) {
49
+ let current = record;
50
+ for (const step of address.slice(0, -1)) current = current[step];
51
+ current[address[address.length - 1]] = value;
52
+ }
53
+
54
+ function* deepStringAddresses(value, address) {
55
+ if (typeof value === 'string') {
56
+ yield [address, value];
57
+ return;
58
+ }
59
+ if (Array.isArray(value)) {
60
+ for (let index = 0; index < value.length; index += 1) {
61
+ yield* deepStringAddresses(value[index], [...address, index]);
62
+ }
63
+ return;
64
+ }
65
+ if (isObject(value)) {
66
+ for (const [key, child] of Object.entries(value)) {
67
+ yield* deepStringAddresses(child, [...address, key]);
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Every field of a tool/action record that may carry a local filesystem path. */
73
+ function* pathCandidates(record) {
74
+ for (const section of ['source', 'sourceAction']) {
75
+ if (record[section] !== undefined) yield* deepStringAddresses(record[section], [section]);
76
+ }
77
+ for (const address of [['guidePath'], ['skillPath'], ['guide', 'path'], ['skill', 'path']]) {
78
+ const value = getAtAddress(record, address);
79
+ if (typeof value === 'string') yield [address, value];
80
+ }
81
+ }
82
+
83
+ function addressLabel(address) {
84
+ if (address.length === 2 && address[0] === 'source' && address[1] === 'cwd') return 'cwd';
85
+ return address.reduce(
86
+ (label, step) => (typeof step === 'number' ? `${label}[${step}]` : label ? `${label}.${step}` : String(step)),
87
+ '',
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Record app-relative bindings on every tool/action whose absolute paths live
93
+ * under a shared app's source directory. Returns the requirement labels for
94
+ * the absolute paths no app covers (still machine-bound after install).
95
+ */
96
+ function annotateAppBoundPaths(record, appCwdBySourceId) {
97
+ const bindings = [];
98
+ const unbound = [];
99
+
100
+ for (const [address, value] of pathCandidates(record)) {
101
+ if (!value.startsWith('/')) continue;
102
+ let bound = false;
103
+ for (const [appId, appCwd] of appCwdBySourceId) {
104
+ const relative = appCwd ? relativeWithin(appCwd, value) : null;
105
+ if (relative === null) continue;
106
+ bindings.push({ appId, path: address, relative });
107
+ bound = true;
108
+ break;
109
+ }
110
+ if (!bound) unbound.push(addressLabel(address));
111
+ }
112
+
113
+ if (bindings.length > 0) record[BINDINGS_KEY] = bindings;
114
+ return { unbound };
115
+ }
116
+
117
+ /**
118
+ * Rewrite a record's bound paths against the installed app directories.
119
+ * Returns warnings for bindings whose app is not part of this install; those
120
+ * fields keep the sharer's original path and stay machine-bound.
121
+ */
122
+ function rewriteAppBoundPaths(record, appDirBySourceId) {
123
+ const bindings = Array.isArray(record[BINDINGS_KEY]) ? record[BINDINGS_KEY] : [];
124
+ if (bindings.length === 0) return { record, warnings: [] };
125
+
126
+ const rewritten = JSON.parse(JSON.stringify(record));
127
+ const warnings = [];
128
+ for (const binding of bindings) {
129
+ const appDir = appDirBySourceId.get(binding.appId);
130
+ if (!appDir) {
131
+ warnings.push(
132
+ `${record.id || 'tool'}: ${addressLabel(binding.path)} is bound to app ${binding.appId}, which is not part of this install`,
133
+ );
134
+ continue;
135
+ }
136
+ setAtAddress(rewritten, binding.path, binding.relative ? path.join(appDir, binding.relative) : appDir);
137
+ }
138
+ delete rewritten[BINDINGS_KEY];
139
+ return { record: rewritten, warnings };
140
+ }
141
+
142
+ module.exports = {
143
+ BINDINGS_KEY,
144
+ annotateAppBoundPaths,
145
+ rewriteAppBoundPaths,
146
+ };
@@ -14,8 +14,10 @@ const PORT = parseInt(process.env.AMALGM_MCP_PORT || '8083', 10);
14
14
  const MCP_PROTOCOL_VERSION = '2024-11-05';
15
15
  const CLI_HOMES_BACKFILL_ID = 'cli-homes-merge-v1';
16
16
 
17
- const AMALGM_DIR = process.env.AMALGM_DIR || path.join(os.homedir(), '.amalgm');
18
- const AMALGM_HOME = process.env.AMALGM_HOME || path.join(os.homedir(), '.amalgm');
17
+ // Layout-aware home: visible ~/amalgm once initialized, legacy ~/.amalgm
18
+ // otherwise. Explicit env always wins (isolated runtimes, tests).
19
+ const AMALGM_HOME = require('./lib/layout').resolveAmalgmHome(process.env);
20
+ const AMALGM_DIR = process.env.AMALGM_DIR || AMALGM_HOME;
19
21
  const AMALGM_RUNTIME_LABEL = normalizeRuntimeLabel(process.env.AMALGM_RUNTIME_LABEL || process.env.AMALGM_BRANCH);
20
22
  const AMALGM_RUNTIME_STATE_DIR = process.env.AMALGM_RUNTIME_STATE_DIR
21
23
  || path.join(AMALGM_DIR, 'runtimes', AMALGM_RUNTIME_LABEL);
@@ -39,7 +39,8 @@ function runtimeLabel() {
39
39
  }
40
40
 
41
41
  function candidateComputerFiles() {
42
- const home = process.env.AMALGM_HOME || path.join(os.homedir(), '.amalgm');
42
+ const layout = require('./layout');
43
+ const home = layout.resolveAmalgmHome(process.env);
43
44
  const label = runtimeLabel();
44
45
  const files = [
45
46
  COMPUTER_RECORD_FILE,
@@ -48,9 +49,10 @@ function candidateComputerFiles() {
48
49
  path.join(home, 'computer.json'),
49
50
  ];
50
51
  if (AMALGM_USER_ID) {
52
+ const userDir = layout.resolveUserDir(home, AMALGM_USER_ID);
51
53
  files.push(
52
- path.join(home, 'users', AMALGM_USER_ID, 'computer.json'),
53
- path.join(home, 'users', AMALGM_USER_ID, 'runtimes', label, 'runtime-state.json'),
54
+ path.join(userDir, 'computer.json'),
55
+ path.join(userDir, 'runtimes', label, 'runtime-state.json'),
54
56
  );
55
57
  }
56
58
  return [...new Set(files)];
@@ -0,0 +1,337 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Amalgm filesystem layout — the one module that knows where Amalgm state
5
+ * lives on a user's machine.
6
+ *
7
+ * Canonical layout (visible root):
8
+ *
9
+ * ~/amalgm/
10
+ * .amalgm-root.json installation identity + user-folder index
11
+ * users/
12
+ * aayush@example.com/ human-readable folder name; NOT the identity
13
+ * .amalgm-user.json stable identity — userId is authoritative
14
+ * agents/ apps/ automations/ toolbox/ workspaces/ ...
15
+ *
16
+ * Rules:
17
+ * - The folder name is presentation; `.amalgm-user.json` is identity. If a
18
+ * folder is renamed, resolution recovers by scanning user manifests.
19
+ * - Legacy layouts (hidden ~/.amalgm, uuid-named user folders) keep working:
20
+ * resolution falls back to them, and migrate-layout leaves compat symlinks
21
+ * at the old locations.
22
+ * - Explicit env (AMALGM_HOME / AMALGM_DIR) always wins — isolated runtimes
23
+ * and tests depend on that.
24
+ */
25
+
26
+ const crypto = require('crypto');
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+ const path = require('path');
30
+
31
+ const ROOT_MANIFEST_NAME = '.amalgm-root.json';
32
+ const USER_MANIFEST_NAME = '.amalgm-user.json';
33
+ const LAYOUT_VERSION = 1;
34
+
35
+ // Per-user roots for platform primitives. Everything Amalgm owns for a user
36
+ // lives under one of these; anything else on disk is the user's own project.
37
+ const PRIMITIVE_DIRS = Object.freeze([
38
+ 'agents',
39
+ 'apps',
40
+ 'automations',
41
+ 'toolbox',
42
+ 'workspaces',
43
+ ]);
44
+
45
+ function readJson(file, fallback = null) {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
48
+ } catch {
49
+ return fallback;
50
+ }
51
+ }
52
+
53
+ function writeJson(file, data) {
54
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
55
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
56
+ }
57
+
58
+ function lstatOrNull(target) {
59
+ try {
60
+ return fs.lstatSync(target);
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /** Same charset as runtime-identity's sanitizeScopeSegment — kept identical so
67
+ * legacy uuid folders resolve to the exact names they were created with. */
68
+ function sanitizeFolderSegment(value, fallback = 'local') {
69
+ const clean = String(value || '')
70
+ .trim()
71
+ .replace(/[^A-Za-z0-9_.@-]/g, '_')
72
+ .replace(/^_+|_+$/g, '');
73
+ return clean || fallback;
74
+ }
75
+
76
+ /** Folder name for a user identified by email: lowercased, same charset. */
77
+ function emailFolderName(email) {
78
+ return sanitizeFolderSegment(String(email || '').trim().toLowerCase(), '');
79
+ }
80
+
81
+ function visibleRootPath() {
82
+ return path.join(os.homedir(), 'amalgm');
83
+ }
84
+
85
+ function hiddenRootPath() {
86
+ return path.join(os.homedir(), '.amalgm');
87
+ }
88
+
89
+ function rootManifestPath(homeDir) {
90
+ return path.join(homeDir, ROOT_MANIFEST_NAME);
91
+ }
92
+
93
+ function userManifestPath(userDir) {
94
+ return path.join(userDir, USER_MANIFEST_NAME);
95
+ }
96
+
97
+ function isAmalgmRoot(dir) {
98
+ return fs.existsSync(rootManifestPath(dir));
99
+ }
100
+
101
+ /**
102
+ * Where Amalgm state lives on this machine.
103
+ *
104
+ * Order: explicit AMALGM_HOME → initialized visible root (~/amalgm with a
105
+ * root manifest) → migrated hidden alias (~/.amalgm symlink → its target) →
106
+ * legacy hidden root (~/.amalgm directory) → visible root for fresh installs.
107
+ */
108
+ function resolveAmalgmHome(env = process.env) {
109
+ if (env.AMALGM_HOME) return path.resolve(env.AMALGM_HOME);
110
+
111
+ const visible = visibleRootPath();
112
+ if (isAmalgmRoot(visible)) return visible;
113
+
114
+ const hidden = hiddenRootPath();
115
+ const hiddenStat = lstatOrNull(hidden);
116
+ if (hiddenStat?.isSymbolicLink()) {
117
+ try {
118
+ const target = fs.realpathSync(hidden);
119
+ return samePath(target, visible) ? visible : target;
120
+ } catch {
121
+ return visible;
122
+ }
123
+ }
124
+ if (hiddenStat) return hidden;
125
+ return visible;
126
+ }
127
+
128
+ /** Compare paths through realpath so macOS aliases like /var → /private/var match. */
129
+ function samePath(a, b) {
130
+ const canonical = (p) => {
131
+ try {
132
+ return fs.realpathSync(p);
133
+ } catch {
134
+ return path.resolve(p);
135
+ }
136
+ };
137
+ return canonical(a) === canonical(b);
138
+ }
139
+
140
+ function readRootManifest(homeDir) {
141
+ return readJson(rootManifestPath(homeDir), null);
142
+ }
143
+
144
+ function ensureRootManifest(homeDir) {
145
+ const existing = readRootManifest(homeDir);
146
+ if (existing?.installationId) return existing;
147
+ const manifest = {
148
+ schemaVersion: LAYOUT_VERSION,
149
+ installationId: existing?.installationId || crypto.randomBytes(8).toString('hex'),
150
+ createdAt: existing?.createdAt || new Date().toISOString(),
151
+ users: existing?.users || {},
152
+ };
153
+ writeJson(rootManifestPath(homeDir), manifest);
154
+ return manifest;
155
+ }
156
+
157
+ /** Record a user folder in the root manifest's index. The index is a cache —
158
+ * user-manifest scans stay authoritative — but it makes the mapping legible. */
159
+ function indexUserFolder(homeDir, folderName, { userId, email }) {
160
+ const manifest = ensureRootManifest(homeDir);
161
+ const entry = manifest.users?.[folderName];
162
+ if (entry?.userId === userId && (entry?.canonicalEmail || '') === (email || '')) return;
163
+ manifest.users = { ...manifest.users, [folderName]: { userId, canonicalEmail: email || '' } };
164
+ writeJson(rootManifestPath(homeDir), manifest);
165
+ }
166
+
167
+ function readUserManifest(userDir) {
168
+ const manifest = readJson(userManifestPath(userDir), null);
169
+ return typeof manifest?.userId === 'string' && manifest.userId.trim() ? manifest : null;
170
+ }
171
+
172
+ function writeUserManifest(userDir, { userId, email }) {
173
+ const existing = readUserManifest(userDir);
174
+ const canonicalEmail = String(email || existing?.canonicalEmail || '').trim().toLowerCase();
175
+ const emailHistory = Array.isArray(existing?.emailHistory) ? [...existing.emailHistory] : [];
176
+ if (canonicalEmail && !emailHistory.includes(canonicalEmail)) emailHistory.push(canonicalEmail);
177
+ const manifest = {
178
+ schemaVersion: LAYOUT_VERSION,
179
+ userId: String(userId).trim(),
180
+ canonicalEmail,
181
+ emailHistory,
182
+ createdAt: existing?.createdAt || new Date().toISOString(),
183
+ updatedAt: new Date().toISOString(),
184
+ };
185
+ writeJson(userManifestPath(userDir), manifest);
186
+ return manifest;
187
+ }
188
+
189
+ /** All user folders under <homeDir>/users. Real directories first, compat
190
+ * symlinks after, both sorted — so scans prefer canonical folders. */
191
+ function listUserEntries(homeDir) {
192
+ const usersDir = path.join(homeDir, 'users');
193
+ let names = [];
194
+ try {
195
+ names = fs.readdirSync(usersDir);
196
+ } catch {
197
+ return [];
198
+ }
199
+ const entries = [];
200
+ for (const name of names.sort()) {
201
+ const dir = path.join(usersDir, name);
202
+ const stat = lstatOrNull(dir);
203
+ if (!stat) continue;
204
+ const isSymlink = stat.isSymbolicLink();
205
+ if (!isSymlink && !stat.isDirectory()) continue;
206
+ if (isSymlink && !fs.existsSync(dir)) continue; // broken link
207
+ entries.push({ name, dir, isSymlink, manifest: readUserManifest(dir) });
208
+ }
209
+ return [...entries.filter((e) => !e.isSymlink), ...entries.filter((e) => e.isSymlink)];
210
+ }
211
+
212
+ /** Find a user's folder by stable identity, regardless of its display name. */
213
+ function findUserDirByUserId(homeDir, userId) {
214
+ const clean = String(userId || '').trim();
215
+ if (!clean) return null;
216
+ for (const entry of listUserEntries(homeDir)) {
217
+ if (entry.manifest?.userId === clean) return entry.dir;
218
+ }
219
+ return null;
220
+ }
221
+
222
+ /**
223
+ * Resolve the storage folder for a user scope (userId or 'local').
224
+ * Manifest identity wins; otherwise fall back to the legacy scope-named
225
+ * folder (which may itself be a compat symlink into the new layout).
226
+ */
227
+ function resolveUserDir(homeDir, scope) {
228
+ const byId = findUserDirByUserId(homeDir, scope);
229
+ if (byId) return byId;
230
+ return path.join(homeDir, 'users', sanitizeFolderSegment(scope));
231
+ }
232
+
233
+ /**
234
+ * Ensure a user folder exists with its identity manifest. When the email is
235
+ * known the folder gets the readable email name (renaming a legacy uuid
236
+ * folder in place, leaving a compat symlink behind).
237
+ */
238
+ function ensureUserDir(homeDir, { userId, email }) {
239
+ const cleanId = String(userId || '').trim();
240
+ if (!cleanId) throw new Error('ensureUserDir requires a userId');
241
+ const cleanEmail = String(email || '').trim().toLowerCase();
242
+
243
+ const existing = findUserDirByUserId(homeDir, cleanId);
244
+ if (existing) {
245
+ if (cleanEmail && readUserManifest(existing)?.canonicalEmail !== cleanEmail) {
246
+ return adoptUserEmail(homeDir, { userId: cleanId, email: cleanEmail }).dir;
247
+ }
248
+ indexUserFolder(homeDir, path.basename(existing), { userId: cleanId, email: cleanEmail });
249
+ return existing;
250
+ }
251
+
252
+ const legacyDir = path.join(homeDir, 'users', sanitizeFolderSegment(cleanId));
253
+ if (lstatOrNull(legacyDir)?.isDirectory()) {
254
+ writeUserManifest(legacyDir, { userId: cleanId, email: cleanEmail });
255
+ if (cleanEmail) return adoptUserEmail(homeDir, { userId: cleanId, email: cleanEmail }).dir;
256
+ indexUserFolder(homeDir, path.basename(legacyDir), { userId: cleanId, email: '' });
257
+ return legacyDir;
258
+ }
259
+
260
+ const folderName = (cleanEmail && emailFolderName(cleanEmail)) || sanitizeFolderSegment(cleanId);
261
+ const dir = path.join(homeDir, 'users', folderName);
262
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
263
+ writeUserManifest(dir, { userId: cleanId, email: cleanEmail });
264
+ indexUserFolder(homeDir, folderName, { userId: cleanId, email: cleanEmail });
265
+ return dir;
266
+ }
267
+
268
+ /**
269
+ * Give a user's folder its readable email name. Renames the current folder
270
+ * and leaves a relative compat symlink at the old name so recorded absolute
271
+ * paths keep resolving. Idempotent; refuses to stomp an unrelated folder.
272
+ */
273
+ function adoptUserEmail(homeDir, { userId, email }) {
274
+ const cleanId = String(userId || '').trim();
275
+ const folderName = emailFolderName(email);
276
+ if (!cleanId || !folderName) throw new Error('adoptUserEmail requires userId and email');
277
+
278
+ const current = findUserDirByUserId(homeDir, cleanId)
279
+ || (lstatOrNull(path.join(homeDir, 'users', sanitizeFolderSegment(cleanId)))?.isDirectory()
280
+ ? path.join(homeDir, 'users', sanitizeFolderSegment(cleanId))
281
+ : null);
282
+ if (!current) throw new Error(`No storage folder found for user ${cleanId}`);
283
+
284
+ writeUserManifest(current, { userId: cleanId, email });
285
+ const target = path.join(homeDir, 'users', folderName);
286
+ if (path.basename(current) === folderName) {
287
+ indexUserFolder(homeDir, folderName, { userId: cleanId, email });
288
+ return { dir: current, renamed: false };
289
+ }
290
+
291
+ const targetStat = lstatOrNull(target);
292
+ if (targetStat) {
293
+ const claimed = targetStat.isDirectory() ? readUserManifest(target)?.userId : null;
294
+ if (claimed !== cleanId) {
295
+ return { dir: current, renamed: false, conflict: folderName };
296
+ }
297
+ indexUserFolder(homeDir, folderName, { userId: cleanId, email });
298
+ return { dir: target, renamed: false };
299
+ }
300
+
301
+ fs.renameSync(current, target);
302
+ fs.symlinkSync(folderName, current); // relative sibling link, relocatable
303
+ indexUserFolder(homeDir, folderName, { userId: cleanId, email });
304
+ return { dir: target, renamed: true, from: current };
305
+ }
306
+
307
+ function userPrimitiveRoot(userDir, kind) {
308
+ if (!PRIMITIVE_DIRS.includes(kind)) {
309
+ throw new Error(`Unknown primitive root "${kind}". Use one of: ${PRIMITIVE_DIRS.join(', ')}`);
310
+ }
311
+ return path.join(userDir, kind);
312
+ }
313
+
314
+ module.exports = {
315
+ samePath,
316
+ LAYOUT_VERSION,
317
+ PRIMITIVE_DIRS,
318
+ ROOT_MANIFEST_NAME,
319
+ USER_MANIFEST_NAME,
320
+ adoptUserEmail,
321
+ emailFolderName,
322
+ ensureRootManifest,
323
+ ensureUserDir,
324
+ findUserDirByUserId,
325
+ hiddenRootPath,
326
+ isAmalgmRoot,
327
+ indexUserFolder,
328
+ listUserEntries,
329
+ readRootManifest,
330
+ readUserManifest,
331
+ resolveAmalgmHome,
332
+ resolveUserDir,
333
+ sanitizeFolderSegment,
334
+ userPrimitiveRoot,
335
+ visibleRootPath,
336
+ writeUserManifest,
337
+ };