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
|
@@ -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
|
-
|
|
901
|
-
|
|
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
|
|
906
|
-
*
|
|
907
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
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(
|
|
53
|
-
path.join(
|
|
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
|
+
};
|