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 +337 -0
- package/lib/migrate-layout.js +212 -0
- package/lib/runtime-identity.js +11 -2
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/agent-bundles/app-entries.js +36 -2
- package/runtime/scripts/amalgm-mcp/agent-bundles/bundles.js +44 -39
- package/runtime/scripts/amalgm-mcp/agent-bundles/tool-bindings.js +146 -0
- package/runtime/scripts/amalgm-mcp/config.js +4 -2
- package/runtime/scripts/amalgm-mcp/lib/computer.js +5 -3
- package/runtime/scripts/amalgm-mcp/lib/layout.js +337 -0
- package/runtime/scripts/amalgm-mcp/tests/bundle-entries.test.js +168 -2
- package/runtime/scripts/lib/project-paths.js +2 -2
- package/runtime/scripts/lib/runtime-paths.js +1 -1
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 };
|
package/lib/runtime-identity.js
CHANGED
|
@@ -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
|
|
131
|
+
return layout.resolveAmalgmHome(env);
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
function scopedAmalgmDir(homeDir, userScope, label) {
|
|
128
135
|
assertRuntimeLabel(label);
|
|
129
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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 = {
|