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.
package/lib/layout.js ADDED
@@ -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
+ };
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * migrate-layout — move a machine onto the canonical Amalgm filesystem layout.
5
+ *
6
+ * Two phases, both idempotent and safe under a live runtime:
7
+ *
8
+ * 1. Root: rename the hidden ~/.amalgm directory to the visible ~/amalgm and
9
+ * leave a symlink at ~/.amalgm. A rename preserves inodes, so processes
10
+ * holding open files (sqlite, logs) are unaffected, and every recorded
11
+ * absolute path keeps resolving through the symlink.
12
+ * 2. Users: give each users/<uuid> folder its readable email name (when the
13
+ * email is known), writing .amalgm-user.json identity manifests and
14
+ * leaving compat symlinks at the uuid names.
15
+ *
16
+ * Run directly:
17
+ * node lib/migrate-layout.js [--home <dir>] [--email <email>]
18
+ * [--user <userId>=<email>]... [--dry-run]
19
+ *
20
+ * --email applies when exactly one user folder exists; --user pins emails to
21
+ * specific user ids. Users with no known email keep their uuid folder but
22
+ * still get an identity manifest.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const os = require('os');
27
+ const path = require('path');
28
+
29
+ const layout = require('./layout');
30
+
31
+ function readJson(file, fallback = null) {
32
+ try {
33
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
34
+ } catch {
35
+ return fallback;
36
+ }
37
+ }
38
+
39
+ function lstatOrNull(target) {
40
+ try {
41
+ return fs.lstatSync(target);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function userIdForDir(dir) {
48
+ const manifest = layout.readUserManifest(dir);
49
+ if (manifest?.userId) return manifest.userId;
50
+ for (const file of ['computer.json', 'auth.json']) {
51
+ const record = readJson(path.join(dir, file), null);
52
+ if (typeof record?.user_id === 'string' && record.user_id.trim()) return record.user_id.trim();
53
+ }
54
+ return '';
55
+ }
56
+
57
+ function isIgnorableEntry(name) {
58
+ return name === '.DS_Store';
59
+ }
60
+
61
+ /** Rename hidden root → visible root and alias the old path. */
62
+ function migrateRoot({ hiddenRoot, visibleRoot, dryRun, actions }) {
63
+ const hiddenStat = lstatOrNull(hiddenRoot);
64
+
65
+ if (hiddenStat?.isSymbolicLink()) {
66
+ const target = fs.realpathSync(hiddenRoot);
67
+ if (!layout.samePath(target, visibleRoot)) {
68
+ throw new Error(`${hiddenRoot} is a symlink to ${target}, expected ${visibleRoot}`);
69
+ }
70
+ return { moved: false, reason: 'already-migrated' };
71
+ }
72
+
73
+ if (!hiddenStat) {
74
+ if (!lstatOrNull(visibleRoot)) {
75
+ actions.push(`create ${visibleRoot}`);
76
+ if (!dryRun) fs.mkdirSync(visibleRoot, { recursive: true, mode: 0o700 });
77
+ }
78
+ return { moved: false, reason: 'no-hidden-root' };
79
+ }
80
+
81
+ if (!hiddenStat.isDirectory()) {
82
+ throw new Error(`${hiddenRoot} exists but is not a directory`);
83
+ }
84
+
85
+ const visibleStat = lstatOrNull(visibleRoot);
86
+ if (visibleStat) {
87
+ if (!visibleStat.isDirectory()) {
88
+ throw new Error(`${visibleRoot} exists but is not a directory`);
89
+ }
90
+ const entries = fs.readdirSync(visibleRoot).filter((name) => !isIgnorableEntry(name));
91
+ if (entries.length > 0) {
92
+ throw new Error(
93
+ `${visibleRoot} already exists and is not empty — refusing to migrate over it`,
94
+ );
95
+ }
96
+ actions.push(`remove empty ${visibleRoot}`);
97
+ if (!dryRun) fs.rmSync(visibleRoot, { recursive: true, force: true });
98
+ }
99
+
100
+ actions.push(`rename ${hiddenRoot} -> ${visibleRoot}`);
101
+ actions.push(`symlink ${hiddenRoot} -> ${path.basename(visibleRoot)}`);
102
+ if (!dryRun) {
103
+ fs.renameSync(hiddenRoot, visibleRoot);
104
+ fs.symlinkSync(path.basename(visibleRoot), hiddenRoot); // relative sibling link
105
+ }
106
+ return { moved: true };
107
+ }
108
+
109
+ /** Write identity manifests and adopt email folder names for every user. */
110
+ function migrateUsers({ homeDir, emailByUserId, fallbackEmail, dryRun, actions }) {
111
+ const results = [];
112
+ const entries = layout.listUserEntries(homeDir).filter((entry) => !entry.isSymlink);
113
+ const soleEntry = entries.length === 1 ? entries[0] : null;
114
+
115
+ for (const entry of entries) {
116
+ const userId = userIdForDir(entry.dir);
117
+ if (!userId) {
118
+ results.push({ folder: entry.name, skipped: 'no-identity' });
119
+ continue;
120
+ }
121
+
122
+ const email = emailByUserId.get(userId)
123
+ || layout.readUserManifest(entry.dir)?.canonicalEmail
124
+ || (entry === soleEntry ? fallbackEmail : '');
125
+
126
+ if (dryRun) {
127
+ const wouldRename = email && layout.emailFolderName(email) !== entry.name;
128
+ actions.push(`manifest ${entry.dir} (userId=${userId}${email ? `, email=${email}` : ''})`);
129
+ if (wouldRename) {
130
+ actions.push(`rename users/${entry.name} -> users/${layout.emailFolderName(email)} (+ symlink)`);
131
+ }
132
+ results.push({ folder: entry.name, userId, email, renamed: Boolean(wouldRename) });
133
+ continue;
134
+ }
135
+
136
+ layout.writeUserManifest(entry.dir, { userId, email });
137
+ if (email) {
138
+ const adopted = layout.adoptUserEmail(homeDir, { userId, email });
139
+ results.push({
140
+ folder: path.basename(adopted.dir),
141
+ userId,
142
+ email,
143
+ renamed: Boolean(adopted.renamed),
144
+ conflict: adopted.conflict || null,
145
+ });
146
+ if (adopted.renamed) actions.push(`renamed users/${entry.name} -> users/${path.basename(adopted.dir)}`);
147
+ } else {
148
+ layout.indexUserFolder(homeDir, entry.name, { userId, email: '' });
149
+ results.push({ folder: entry.name, userId, email: '', renamed: false });
150
+ }
151
+ }
152
+ return results;
153
+ }
154
+
155
+ /**
156
+ * Migrate a machine to the canonical layout. Options:
157
+ * home OS home directory (default os.homedir())
158
+ * users [{ userId, email }] explicit email assignments
159
+ * email fallback email when exactly one user folder exists
160
+ * dryRun report actions without touching the filesystem
161
+ */
162
+ function migrateLayout(options = {}) {
163
+ const home = options.home || os.homedir();
164
+ const hiddenRoot = path.join(home, '.amalgm');
165
+ const visibleRoot = path.join(home, 'amalgm');
166
+ const dryRun = Boolean(options.dryRun);
167
+ const actions = [];
168
+
169
+ const emailByUserId = new Map();
170
+ for (const user of options.users || []) {
171
+ if (user?.userId && user?.email) {
172
+ emailByUserId.set(String(user.userId).trim(), String(user.email).trim().toLowerCase());
173
+ }
174
+ }
175
+
176
+ const root = migrateRoot({ hiddenRoot, visibleRoot, dryRun, actions });
177
+ if (!dryRun) layout.ensureRootManifest(visibleRoot);
178
+
179
+ const users = migrateUsers({
180
+ // On a dry run the root has not actually moved yet — preview the user
181
+ // folders where they currently live.
182
+ homeDir: dryRun && root.moved ? hiddenRoot : visibleRoot,
183
+ emailByUserId,
184
+ fallbackEmail: String(options.email || '').trim().toLowerCase(),
185
+ dryRun,
186
+ actions,
187
+ });
188
+
189
+ return { home, root: visibleRoot, rootMoved: Boolean(root.moved), users, actions, dryRun };
190
+ }
191
+
192
+ function parseArgs(argv) {
193
+ const options = { users: [] };
194
+ for (let index = 0; index < argv.length; index += 1) {
195
+ const arg = argv[index];
196
+ if (arg === '--dry-run') options.dryRun = true;
197
+ else if (arg === '--home') options.home = argv[++index];
198
+ else if (arg === '--email') options.email = argv[++index];
199
+ else if (arg === '--user') {
200
+ const [userId, email] = String(argv[++index] || '').split('=');
201
+ options.users.push({ userId, email });
202
+ } else throw new Error(`Unknown argument: ${arg}`);
203
+ }
204
+ return options;
205
+ }
206
+
207
+ if (require.main === module) {
208
+ const report = migrateLayout(parseArgs(process.argv.slice(2)));
209
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
210
+ }
211
+
212
+ module.exports = { migrateLayout };
@@ -5,6 +5,8 @@ const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
7
 
8
+ const layout = require('./layout');
9
+
8
10
  const VALID_RUNTIME_LABELS = new Set(['main', 'preview', 'local']);
9
11
 
10
12
  function normalizeRuntimeLabel(value) {
@@ -82,6 +84,11 @@ function discoverLegacyUserScope(homeDir, label = '') {
82
84
  const fromAuth = userIdFromRecord(auth);
83
85
  if (fromAuth) return fromAuth;
84
86
 
87
+ // Identity manifests are authoritative when present (canonical layout).
88
+ for (const entry of layout.listUserEntries(homeDir)) {
89
+ if (entry.manifest?.userId) return entry.manifest.userId;
90
+ }
91
+
85
92
  const runtimeLabel = normalizeRuntimeLabel(label);
86
93
  if (!runtimeLabel) return '';
87
94
  try {
@@ -121,12 +128,14 @@ function resolveRuntimeUserScope(env = process.env, fallback = '') {
121
128
  }
122
129
 
123
130
  function defaultAmalgmHome(env = process.env) {
124
- return env.AMALGM_HOME || path.join(os.homedir(), '.amalgm');
131
+ return layout.resolveAmalgmHome(env);
125
132
  }
126
133
 
127
134
  function scopedAmalgmDir(homeDir, userScope, label) {
128
135
  assertRuntimeLabel(label);
129
- return path.join(homeDir, 'users', sanitizeScopeSegment(userScope));
136
+ // Manifest identity wins over folder naming; falls back to the legacy
137
+ // scope-named folder for machines without identity manifests.
138
+ return layout.resolveUserDir(homeDir, sanitizeScopeSegment(userScope));
130
139
  }
131
140
 
132
141
  function scopedRuntimeDir(homeDir, userScope, label) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.137",
3
+ "version": "0.1.138",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -17,7 +17,7 @@
17
17
  "sync-runtime": "node ../../scripts/sync-npm-package-runtime.mjs",
18
18
  "prepack": "node ../../scripts/sync-npm-package-runtime.mjs",
19
19
  "pack:dry": "npm pack --dry-run",
20
- "check": "node --check bin/amalgm.js && node --check lib/app-chat.js && node --check lib/react.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check lib/updater.js && node --check runtime/lib/runtime-manifest.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/amalgm-mcp/events/pr-check-runner.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
20
+ "check": "node --check bin/amalgm.js && node --check lib/app-chat.js && node --check lib/react.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/layout.js && node --check lib/migrate-layout.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check lib/updater.js && node --check runtime/lib/runtime-manifest.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/amalgm-mcp/events/pr-check-runner.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
21
21
  },
22
22
  "engines": {
23
23
  "node": ">=20"
@@ -25,6 +25,17 @@ function cleanString(value) {
25
25
  return typeof value === 'string' ? value.trim() : '';
26
26
  }
27
27
 
28
+ const NODE_INSTALL_COMMANDS = [
29
+ ['pnpm-lock.yaml', 'pnpm install --frozen-lockfile'],
30
+ ['yarn.lock', 'yarn install --frozen-lockfile'],
31
+ ['bun.lockb', 'bun install --frozen-lockfile'],
32
+ ['bun.lock', 'bun install --frozen-lockfile'],
33
+ ['package-lock.json', 'npm install'],
34
+ ['npm-shrinkwrap.json', 'npm install'],
35
+ ];
36
+
37
+ const NODE_INSTALL_PATTERN = /\b(?:npm\s+(?:ci|install)|pnpm\s+install|yarn\s+install|bun\s+install)\b/;
38
+
28
39
  function slugify(value) {
29
40
  return String(value || '')
30
41
  .toLowerCase()
@@ -33,6 +44,22 @@ function slugify(value) {
33
44
  .slice(0, 60) || 'app';
34
45
  }
35
46
 
47
+ function nodeBootstrapCommand(targetDir) {
48
+ if (!fs.existsSync(path.join(targetDir, 'package.json'))) return null;
49
+ for (const [marker, command] of NODE_INSTALL_COMMANDS) {
50
+ if (fs.existsSync(path.join(targetDir, marker))) return command;
51
+ }
52
+ return 'npm install';
53
+ }
54
+
55
+ function prepareBuildCommand(targetDir, buildCommand) {
56
+ const cleanedBuild = cleanString(buildCommand);
57
+ const bootstrap = nodeBootstrapCommand(targetDir);
58
+ if (!bootstrap) return cleanedBuild || null;
59
+ if (cleanedBuild && NODE_INSTALL_PATTERN.test(cleanedBuild)) return cleanedBuild;
60
+ return cleanedBuild ? `${bootstrap} && ${cleanedBuild}` : bootstrap;
61
+ }
62
+
36
63
  function exportAppEntry(appId, options = {}) {
37
64
  const app = getApp(appId);
38
65
  if (!app) throw new Error(`App not found: ${appId}`);
@@ -62,6 +89,9 @@ function exportAppEntry(appId, options = {}) {
62
89
  skipped: packed.skipped,
63
90
  },
64
91
  skipped: packed.skipped,
92
+ // Export-time only (never stored in the bundle): lets the exporter bind
93
+ // tool paths that live inside this app's directory.
94
+ sourceCwd: app.cwd,
65
95
  };
66
96
  }
67
97
 
@@ -96,11 +126,12 @@ function materializeAppEntry(entry, options = {}) {
96
126
  if (!cleanString(options.rootDir)) throw new Error('rootDir is required');
97
127
  const targetDir = allocateInstallDir(path.resolve(options.rootDir), entry.app.name);
98
128
  unpackAppFiles(entry.files, targetDir);
129
+ const buildCommand = prepareBuildCommand(targetDir, entry.app.buildCommand);
99
130
  return {
100
131
  name: entry.app.name || 'App',
101
132
  description: entry.app.description || '',
102
133
  cwd: targetDir,
103
- build_command: entry.app.buildCommand || null,
134
+ build_command: buildCommand,
104
135
  start_command: entry.app.startCommand,
105
136
  autostart: entry.app.autostart !== false,
106
137
  keep_alive: entry.app.keepAlive !== false,
@@ -114,7 +145,10 @@ function materializeAppEntry(entry, options = {}) {
114
145
  async function installAppEntry(entry, options = {}) {
115
146
  const register = options.register || registerApp;
116
147
  const input = materializeAppEntry(entry, options);
117
- return register(input);
148
+ const record = await register(input);
149
+ // The materialized directory anchors app-bound tool path rewrites; make
150
+ // sure it survives register implementations that omit cwd.
151
+ return { ...record, cwd: record?.cwd || input.cwd };
118
152
  }
119
153
 
120
154
  module.exports = {