amalgm 0.1.136 → 0.1.137

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.136",
3
+ "version": "0.1.137",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -216,6 +216,17 @@ function cleanEntryId(value) {
216
216
  return typeof value === 'string' ? value.trim() : '';
217
217
  }
218
218
 
219
+ /**
220
+ * Tools shared as top-level primitives (not just dependencies of agents or
221
+ * automations). The bundle's explicit heads are the single source of truth.
222
+ */
223
+ function standaloneToolIds(bundle) {
224
+ const heads = Array.isArray(bundle?.heads) ? bundle.heads : [];
225
+ return uniqueStrings(heads
226
+ .filter((head) => isObject(head) && cleanEntryId(head.type) === 'tool')
227
+ .map((head) => cleanEntryId(head.id)));
228
+ }
229
+
219
230
  /**
220
231
  * App elements carry the portable record and a file manifest; file contents
221
232
  * stay in bundle.apps[] so the graph remains cheap to render.
@@ -353,7 +364,10 @@ function buildFlatAgentBundleGraph(bundle) {
353
364
  }
354
365
  }
355
366
 
356
- for (const toolId of includedToolIds) {
367
+ // Every tool in bundle.tools gets an element — standalone tool heads are
368
+ // not referenced by any agent or automation edge but must still resolve.
369
+ const toolElementIds = new Set([...includedToolIds, ...toolMap.keys()]);
370
+ for (const toolId of toolElementIds) {
357
371
  const tool = toolMap.get(toolId);
358
372
  pushGraphElement(elements, {
359
373
  id: graphElementId('tool', toolId),
@@ -370,7 +384,7 @@ function buildFlatAgentBundleGraph(bundle) {
370
384
  }
371
385
 
372
386
  for (const action of Array.isArray(bundle.toolActions) ? bundle.toolActions : []) {
373
- if (!includedToolIds.has(action.toolId)) continue;
387
+ if (!toolElementIds.has(action.toolId)) continue;
374
388
  includedActionIds.add(action.id);
375
389
  pushGraphElement(elements, {
376
390
  id: graphElementId('tool_action', action.id),
@@ -529,7 +543,7 @@ function collectAgentGraph(rootAgentId) {
529
543
  return ordered;
530
544
  }
531
545
 
532
- function bundleTitleAndDescription(agents, automations, apps) {
546
+ function bundleTitleAndDescription(agents, automations, apps, sharedTools) {
533
547
  const firstAgent = agents[0]?.agent;
534
548
  if (firstAgent) {
535
549
  return { title: firstAgent.name || 'Shared agent', description: firstAgent.description || '' };
@@ -542,6 +556,10 @@ function bundleTitleAndDescription(agents, automations, apps) {
542
556
  if (firstApp) {
543
557
  return { title: firstApp.name || 'Shared app', description: firstApp.description || '' };
544
558
  }
559
+ const firstTool = sharedTools?.[0];
560
+ if (firstTool) {
561
+ return { title: firstTool.name || 'Shared tool', description: firstTool.description || '' };
562
+ }
545
563
  return { title: 'Shared bundle', description: '' };
546
564
  }
547
565
 
@@ -566,8 +584,9 @@ function createBundle(input = {}, options = {}) {
566
584
  const agentIds = uniqueStrings(input.agentIds);
567
585
  const automationIds = uniqueStrings(input.automationIds);
568
586
  const appIds = uniqueStrings(input.appIds);
569
- if (agentIds.length === 0 && automationIds.length === 0 && appIds.length === 0) {
570
- throw new Error('At least one agent, automation, or app is required');
587
+ const toolIds = uniqueStrings(input.toolIds);
588
+ if (agentIds.length === 0 && automationIds.length === 0 && appIds.length === 0 && toolIds.length === 0) {
589
+ throw new Error('At least one agent, automation, app, or tool is required');
571
590
  }
572
591
 
573
592
  const requires = {
@@ -614,7 +633,15 @@ function createBundle(input = {}, options = {}) {
614
633
  skippedAppFiles += exported.skipped.length;
615
634
  }
616
635
 
617
- const { tools, toolActions } = collectToolRecords(loadoutToolIds, requires);
636
+ const { tools, toolActions } = collectToolRecords([...loadoutToolIds, ...toolIds], requires);
637
+ // Standalone tool heads must resolve to a real catalog tool — a dependency
638
+ // tool that is missing degrades to a placeholder, but a head cannot.
639
+ const sharedToolIds = toolIds.filter((toolId) => tools.some((tool) => tool.id === toolId));
640
+ const missingSharedToolIds = toolIds.filter((toolId) => !sharedToolIds.includes(toolId));
641
+ if (missingSharedToolIds.length > 0) {
642
+ throw new Error(`Tool not found: ${missingSharedToolIds.join(', ')}`);
643
+ }
644
+ const sharedTools = sharedToolIds.map((toolId) => tools.find((tool) => tool.id === toolId));
618
645
  const skills = collectSkillRecords(agents);
619
646
  const hooks = countHooks(agents);
620
647
  const warnings = bundleWarnings({
@@ -624,15 +651,18 @@ function createBundle(input = {}, options = {}) {
624
651
  apps: apps.length,
625
652
  skippedAppFiles,
626
653
  });
627
- const { title, description } = bundleTitleAndDescription(agents, automations, apps);
654
+ const { title, description } = bundleTitleAndDescription(agents, automations, apps, sharedTools);
628
655
  const heads = [
629
656
  ...agentIds.map((id) => ({ type: 'agent', id })),
630
657
  ...automationIds.map((id) => ({ type: 'automation', id })),
631
658
  ...appIds.map((id) => ({ type: 'app', id })),
659
+ ...sharedToolIds.map((id) => ({ type: 'tool', id })),
632
660
  ];
633
661
 
634
662
  const bundle = withBundleGraph({
635
- kind: automations.length > 0 || apps.length > 0 ? BUNDLE_KIND : LEGACY_AGENT_BUNDLE_KIND,
663
+ kind: automations.length > 0 || apps.length > 0 || sharedToolIds.length > 0
664
+ ? BUNDLE_KIND
665
+ : LEGACY_AGENT_BUNDLE_KIND,
636
666
  schemaVersion: BUNDLE_SCHEMA_VERSION,
637
667
  bundleId: options.bundleId || bundleId(),
638
668
  createdAt: nowIso(),
@@ -778,8 +808,9 @@ function validateBundle(bundle) {
778
808
  const agents = Array.isArray(bundle.agents) ? bundle.agents : [];
779
809
  const automations = Array.isArray(bundle.automations) ? bundle.automations : [];
780
810
  const apps = Array.isArray(bundle.apps) ? bundle.apps : [];
781
- if (agents.length === 0 && automations.length === 0 && apps.length === 0) {
782
- throw new Error('bundle must include at least one agent, automation, or app');
811
+ const toolHeads = standaloneToolIds(bundle);
812
+ if (agents.length === 0 && automations.length === 0 && apps.length === 0 && toolHeads.length === 0) {
813
+ throw new Error('bundle must include at least one agent, automation, app, or tool');
783
814
  }
784
815
  if (agents.length > 0) assertClosedAgentBundleGraph(bundle);
785
816
  return withBundleGraph(bundle);
@@ -22,12 +22,13 @@ async function handlePreview(body, sendJson) {
22
22
  const agentIds = idList(body?.agent_id, body?.agent_ids);
23
23
  const automationIds = idList(body?.automation_id, body?.automation_ids);
24
24
  const appIds = idList(body?.app_id, body?.app_ids);
25
- if (agentIds.length === 0 && automationIds.length === 0 && appIds.length === 0) {
26
- return sendJson(400, { error: 'At least one of agent_ids, automation_ids, or app_ids is required' });
25
+ const toolIds = idList(body?.tool_id, body?.tool_ids);
26
+ if (agentIds.length === 0 && automationIds.length === 0 && appIds.length === 0 && toolIds.length === 0) {
27
+ return sendJson(400, { error: 'At least one of agent_ids, automation_ids, app_ids, or tool_ids is required' });
27
28
  }
28
29
 
29
30
  try {
30
- const result = createBundle({ agentIds, automationIds, appIds });
31
+ const result = createBundle({ agentIds, automationIds, appIds, toolIds });
31
32
  return sendJson(200, { ok: true, ...result });
32
33
  } catch (error) {
33
34
  return sendJson(400, { error: error.message || 'Failed to create bundle' });
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
3
4
  const fs = require('fs');
4
5
  const os = require('os');
5
6
  const path = require('path');
@@ -19,11 +20,105 @@ const DEFAULT_DESKTOP_UPDATE_BASE_URL = 'https://amalgm-desktop-releases.fly.dev
19
20
  const DEFAULT_DESKTOP_RELEASE_FLY_APP = 'amalgm-desktop-releases';
20
21
  const DEFAULT_DESKTOP_RELEASE_FLY_ROOT = '/data';
21
22
  const DEFAULT_DESKTOP_RELEASE_UPLOAD_CHUNK_BYTES = 2 * 1024 * 1024;
23
+ const DEFAULT_DESKTOP_NOTARY_SUBMISSION_DISCOVERY_TIMEOUT_MS = 2 * 60 * 1000;
24
+ const DEFAULT_DESKTOP_NOTARY_DECISION_TIMEOUT_MS = 2 * 60 * 60 * 1000;
25
+ const DEFAULT_DESKTOP_NOTARY_POLL_INTERVAL_MS = 15 * 1000;
22
26
 
23
27
  function cleanString(value) {
24
28
  return typeof value === 'string' ? value.trim() : '';
25
29
  }
26
30
 
31
+ function booleanEnv(value, defaultValue = false) {
32
+ const raw = cleanString(value).toLowerCase();
33
+ if (!raw) return defaultValue;
34
+ if (['1', 'true', 'yes', 'on'].includes(raw)) return true;
35
+ if (['0', 'false', 'no', 'off'].includes(raw)) return false;
36
+ throw new Error(`Invalid boolean environment value: ${value}`);
37
+ }
38
+
39
+ function manualDesktopNotarizationEnabled(env = process.env) {
40
+ return booleanEnv(env.AMALGM_DESKTOP_RELEASE_MANUAL_NOTARIZE, true);
41
+ }
42
+
43
+ function codesignIdentityForCli(env = process.env) {
44
+ const raw = cleanString(
45
+ env.AMALGM_DESKTOP_CODESIGN_IDENTITY
46
+ || env.CSC_NAME
47
+ || DEFAULT_CSC_NAME,
48
+ ).replace(/^Developer ID Application:\s*/i, '');
49
+ if (!raw) throw new Error('Missing macOS code signing identity for manual DMG signing.');
50
+ return `Developer ID Application: ${raw}`;
51
+ }
52
+
53
+ function appBuilderBinaryRelativePath(platform = process.platform, arch = process.arch) {
54
+ if (platform === 'darwin') {
55
+ return path.join('mac', arch === 'arm64' ? 'app-builder_arm64' : 'app-builder_amd64');
56
+ }
57
+ if (platform === 'linux') {
58
+ return path.join('linux', arch, 'app-builder');
59
+ }
60
+ if (platform === 'win32') {
61
+ if (arch === 'arm64') return path.join('win', 'arm64', 'app-builder.exe');
62
+ if (arch === 'ia32') return path.join('win', 'ia32', 'app-builder.exe');
63
+ return path.join('win', 'x64', 'app-builder.exe');
64
+ }
65
+ throw new Error(`Unsupported platform for app-builder binary lookup: ${platform}`);
66
+ }
67
+
68
+ function appBuilderBinaryPath(engineDir, env = process.env, platform = process.platform, arch = process.arch) {
69
+ const override = cleanString(env.AMALGM_DESKTOP_APP_BUILDER_BIN);
70
+ if (override) return override;
71
+ const binaryPath = path.join(
72
+ engineDir,
73
+ 'node_modules',
74
+ 'app-builder-bin',
75
+ appBuilderBinaryRelativePath(platform, arch),
76
+ );
77
+ if (!fs.existsSync(binaryPath)) {
78
+ throw new Error(`Missing app-builder binary for desktop blockmap generation: ${binaryPath}`);
79
+ }
80
+ return binaryPath;
81
+ }
82
+
83
+ function notarytoolCredentialArgs(env = process.env) {
84
+ const apiKey = cleanString(env.APPLE_API_KEY);
85
+ const apiKeyId = cleanString(env.APPLE_API_KEY_ID);
86
+ const apiIssuer = cleanString(env.APPLE_API_ISSUER);
87
+ if (apiKey || apiKeyId || apiIssuer) {
88
+ if (!apiKey || !apiKeyId || !apiIssuer) {
89
+ throw new Error('Incomplete Apple API key notarization credentials. Expected APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER.');
90
+ }
91
+ if (!fs.existsSync(apiKey)) throw new Error(`APPLE_API_KEY file does not exist: ${apiKey}`);
92
+ return {
93
+ args: ['--key', apiKey, '--key-id', apiKeyId, '--issuer', apiIssuer],
94
+ credentialType: 'api-key',
95
+ };
96
+ }
97
+
98
+ const appleId = cleanString(env.APPLE_ID);
99
+ const appPassword = cleanString(env.APPLE_APP_SPECIFIC_PASSWORD);
100
+ const teamId = cleanString(env.APPLE_TEAM_ID);
101
+ if (appleId || appPassword || teamId) {
102
+ if (!appleId || !appPassword || !teamId) {
103
+ throw new Error('Incomplete Apple ID notarization credentials. Expected APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID.');
104
+ }
105
+ return {
106
+ args: ['--apple-id', appleId, '--password', appPassword, '--team-id', teamId],
107
+ credentialType: 'apple-id',
108
+ };
109
+ }
110
+
111
+ const keychainProfile = cleanString(env.APPLE_KEYCHAIN_PROFILE);
112
+ if (keychainProfile) {
113
+ return {
114
+ args: ['--keychain-profile', keychainProfile],
115
+ credentialType: 'keychain-profile',
116
+ };
117
+ }
118
+
119
+ throw new Error('Missing Apple notarization credentials for manual desktop release notarization.');
120
+ }
121
+
27
122
  function parseRepoFullName(value) {
28
123
  const raw = cleanString(value);
29
124
  const match = /^([^/\s]+)\/([^/\s]+)$/.exec(raw);
@@ -348,7 +443,7 @@ function appendReleaseLog(filePath, text) {
348
443
  fs.appendFileSync(logFile, text);
349
444
  }
350
445
 
351
- function run(command, args, options = {}) {
446
+ function runResult(command, args, options = {}) {
352
447
  const printable = [command, ...args].join(' ');
353
448
  const logFile = releaseLogFile(options);
354
449
  appendReleaseLog(logFile, [
@@ -372,6 +467,12 @@ function run(command, args, options = {}) {
372
467
  if (result.stdout) process.stdout.write(result.stdout);
373
468
  if (result.stderr) process.stderr.write(result.stderr);
374
469
  if (result.error) throw result.error;
470
+ return result;
471
+ }
472
+
473
+ function run(command, args, options = {}) {
474
+ const printable = [command, ...args].join(' ');
475
+ const result = runResult(command, args, options);
375
476
  if (result.status !== 0) {
376
477
  throw new Error(`${printable} failed with exit code ${result.status ?? result.signal ?? 'unknown'}`);
377
478
  }
@@ -582,7 +683,7 @@ function envForBuild(
582
683
  ...baseEnv,
583
684
  DESKTOP_RELEASE_LANE: request.lane,
584
685
  DESKTOP_RELEASE_CHANNEL: request.lane === 'preview' ? 'preview' : 'latest',
585
- AMALGM_DESKTOP_THIN: cleanString(baseEnv.AMALGM_DESKTOP_THIN) || '1',
686
+ AMALGM_DESKTOP_THIN: desktopThinRuntimeBuildEnabled(baseEnv) ? '1' : '0',
586
687
  AMALGM_RUNTIME_LABEL: request.lane === 'preview' ? 'preview' : 'main',
587
688
  AMALGM_BRANCH: request.lane,
588
689
  ELECTRON_VERSION: version,
@@ -597,6 +698,9 @@ function envForBuild(
597
698
  AMALGM_DESKTOP_UPDATE_PROVIDER: publishTarget.updateProvider || normalizeUpdateProviderForRelease(publishTarget.provider),
598
699
  CSC_NAME: baseEnv.CSC_NAME || DEFAULT_CSC_NAME,
599
700
  };
701
+ if (manualDesktopNotarizationEnabled(baseEnv)) {
702
+ env.AMALGM_DESKTOP_NOTARIZE = 'off';
703
+ }
600
704
  if (token) {
601
705
  env.GH_TOKEN = token;
602
706
  env.GITHUB_TOKEN = token;
@@ -625,6 +729,14 @@ function envForBuild(
625
729
  return env;
626
730
  }
627
731
 
732
+ function desktopThinRuntimeBuildEnabled(env = process.env) {
733
+ const raw = cleanString(env.AMALGM_DESKTOP_THIN || env.DESKTOP_THIN_BUILD).toLowerCase();
734
+ if (!raw) return true;
735
+ if (['1', 'true', 'yes', 'on'].includes(raw)) return true;
736
+ if (['0', 'false', 'no', 'off'].includes(raw)) return false;
737
+ throw new Error(`Unsupported AMALGM_DESKTOP_THIN value: ${raw}`);
738
+ }
739
+
628
740
  function appOriginForLane(lane, env = process.env) {
629
741
  if (lane === 'preview') {
630
742
  return cleanString(env.AMALGM_DESKTOP_PREVIEW_APP_ORIGIN)
@@ -700,6 +812,295 @@ function desktopReleaseArtifactPaths(buildDir, lane, version) {
700
812
  ];
701
813
  }
702
814
 
815
+ function desktopAppBundlePath(engineDir, lane) {
816
+ const appName = lane === 'preview' ? 'amalgm-preview.app' : 'amalgm.app';
817
+ return path.join(engineDir, 'build', 'mac-arm64', appName);
818
+ }
819
+
820
+ function releaseArtifactPath(artifactPaths, suffix) {
821
+ const match = artifactPaths.find((filePath) => (
822
+ filePath.endsWith(suffix)
823
+ && !filePath.endsWith(`${suffix}.blockmap`)
824
+ ));
825
+ if (!match) throw new Error(`Missing desktop release artifact with suffix ${suffix}`);
826
+ return match;
827
+ }
828
+
829
+ function parseJsonOutput(raw, label) {
830
+ try {
831
+ return JSON.parse(String(raw || ''));
832
+ } catch (error) {
833
+ throw new Error(`Failed to parse ${label} JSON: ${error.message}`);
834
+ }
835
+ }
836
+
837
+ function sha512Base64(filePath) {
838
+ const hash = crypto.createHash('sha512');
839
+ hash.update(fs.readFileSync(filePath));
840
+ return hash.digest('base64');
841
+ }
842
+
843
+ function updateChannelArtifactDigest(channelPath, filePath) {
844
+ if (!channelPath) return;
845
+ const raw = fs.readFileSync(channelPath, 'utf8');
846
+ const info = yaml.load(raw);
847
+ if (!info || typeof info !== 'object' || Array.isArray(info)) {
848
+ throw new Error(`Desktop release channel file is not a YAML object: ${channelPath}`);
849
+ }
850
+
851
+ const assetName = path.basename(filePath);
852
+ const size = fs.statSync(filePath).size;
853
+ const sha512 = sha512Base64(filePath);
854
+ if (Array.isArray(info.files)) {
855
+ for (const file of info.files) {
856
+ if (!file || typeof file !== 'object' || Array.isArray(file)) continue;
857
+ if (cleanString(file.url) !== assetName) continue;
858
+ file.size = size;
859
+ file.sha512 = sha512;
860
+ }
861
+ }
862
+ if (cleanString(info.path) === assetName) {
863
+ info.sha512 = sha512;
864
+ }
865
+
866
+ fs.writeFileSync(channelPath, yaml.dump(info, {
867
+ lineWidth: -1,
868
+ noRefs: true,
869
+ sortKeys: false,
870
+ }));
871
+ }
872
+
873
+ function regenerateArtifactBlockmap(engineDir, artifactPath, env = process.env) {
874
+ const blockmapPath = `${artifactPath}.blockmap`;
875
+ run(appBuilderBinaryPath(engineDir, env), [
876
+ 'blockmap',
877
+ '--input',
878
+ artifactPath,
879
+ '--output',
880
+ blockmapPath,
881
+ ], {
882
+ cwd: engineDir,
883
+ env,
884
+ maxBuffer: 20 * 1024 * 1024,
885
+ });
886
+ return blockmapPath;
887
+ }
888
+
889
+ function recreateZipFromAppBundle(engineDir, appPath, zipPath, env = process.env) {
890
+ const tempZipPath = `${zipPath}.tmp-${process.pid}`;
891
+ fs.rmSync(tempZipPath, { force: true });
892
+ try {
893
+ run('ditto', [
894
+ '-c',
895
+ '-k',
896
+ '--sequesterRsrc',
897
+ '--keepParent',
898
+ appPath,
899
+ tempZipPath,
900
+ ], {
901
+ cwd: engineDir,
902
+ env,
903
+ maxBuffer: 20 * 1024 * 1024,
904
+ });
905
+ fs.renameSync(tempZipPath, zipPath);
906
+ return zipPath;
907
+ } catch (error) {
908
+ fs.rmSync(tempZipPath, { force: true });
909
+ throw error;
910
+ }
911
+ }
912
+
913
+ function parseNotarySubmissionId(payload) {
914
+ return cleanString(
915
+ payload?.id
916
+ || payload?.submissionId
917
+ || payload?.notarizationUpload?.id,
918
+ );
919
+ }
920
+
921
+ function parseNotaryStatus(payload) {
922
+ return cleanString(
923
+ payload?.status
924
+ || payload?.Status
925
+ || payload?.notarizationUpload?.status,
926
+ );
927
+ }
928
+
929
+ function notaryPollIntervalMs(env = process.env) {
930
+ return Math.max(1_000, Number(env.AMALGM_DESKTOP_NOTARY_POLL_INTERVAL_MS) || DEFAULT_DESKTOP_NOTARY_POLL_INTERVAL_MS);
931
+ }
932
+
933
+ function notarySubmissionDiscoveryTimeoutMs(env = process.env) {
934
+ return Math.max(5_000, Number(env.AMALGM_DESKTOP_NOTARY_SUBMISSION_DISCOVERY_TIMEOUT_MS) || DEFAULT_DESKTOP_NOTARY_SUBMISSION_DISCOVERY_TIMEOUT_MS);
935
+ }
936
+
937
+ function notaryDecisionTimeoutMs(env = process.env) {
938
+ return Math.max(30_000, Number(env.AMALGM_DESKTOP_NOTARY_DECISION_TIMEOUT_MS) || DEFAULT_DESKTOP_NOTARY_DECISION_TIMEOUT_MS);
939
+ }
940
+
941
+ function notaryHistoryEntries(cwd, env, authArgs) {
942
+ const stdout = run('xcrun', [
943
+ 'notarytool',
944
+ 'history',
945
+ ...authArgs,
946
+ '--output-format',
947
+ 'json',
948
+ ], {
949
+ cwd,
950
+ env,
951
+ maxBuffer: 10 * 1024 * 1024,
952
+ });
953
+ const parsed = parseJsonOutput(stdout, 'notarytool history');
954
+ if (Array.isArray(parsed?.history)) return parsed.history;
955
+ if (Array.isArray(parsed?.submissions)) return parsed.submissions;
956
+ if (Array.isArray(parsed)) return parsed;
957
+ return [];
958
+ }
959
+
960
+ function waitForNotarySubmission(artifactName, startedAtMs, cwd, env, authArgs) {
961
+ const deadline = Date.now() + notarySubmissionDiscoveryTimeoutMs(env);
962
+ const threshold = startedAtMs - 60_000;
963
+ while (true) {
964
+ const entries = notaryHistoryEntries(cwd, env, authArgs)
965
+ .filter((entry) => cleanString(entry?.name) === artifactName)
966
+ .filter((entry) => {
967
+ const createdAt = Date.parse(cleanString(entry?.createdDate));
968
+ return !Number.isNaN(createdAt) && createdAt >= threshold;
969
+ })
970
+ .sort((left, right) => Date.parse(cleanString(right?.createdDate)) - Date.parse(cleanString(left?.createdDate)));
971
+ if (entries.length > 0) return entries[0];
972
+ if (Date.now() >= deadline) {
973
+ throw new Error(`Timed out waiting for notary submission to appear for ${artifactName}.`);
974
+ }
975
+ sleepSync(notaryPollIntervalMs(env));
976
+ }
977
+ }
978
+
979
+ function notaryInfo(submissionId, cwd, env, authArgs) {
980
+ const stdout = run('xcrun', [
981
+ 'notarytool',
982
+ 'info',
983
+ submissionId,
984
+ ...authArgs,
985
+ '--output-format',
986
+ 'json',
987
+ ], {
988
+ cwd,
989
+ env,
990
+ maxBuffer: 10 * 1024 * 1024,
991
+ });
992
+ return parseJsonOutput(stdout, 'notarytool info');
993
+ }
994
+
995
+ function waitForNotaryDecision(submissionId, artifactName, cwd, env, authArgs) {
996
+ const deadline = Date.now() + notaryDecisionTimeoutMs(env);
997
+ let lastStatus = '';
998
+ while (true) {
999
+ const info = notaryInfo(submissionId, cwd, env, authArgs);
1000
+ const status = parseNotaryStatus(info);
1001
+ if (status && status !== 'In Progress') {
1002
+ if (status !== 'Accepted') {
1003
+ throw new Error(`Notary submission ${submissionId} for ${artifactName} finished with status ${status}.`);
1004
+ }
1005
+ return info;
1006
+ }
1007
+ lastStatus = status || lastStatus || 'In Progress';
1008
+ if (Date.now() >= deadline) {
1009
+ throw new Error(`Timed out waiting for notary submission ${submissionId} for ${artifactName}; last status ${lastStatus}.`);
1010
+ }
1011
+ sleepSync(notaryPollIntervalMs(env));
1012
+ }
1013
+ }
1014
+
1015
+ function submitArtifactForNotarization(filePath, cwd, env) {
1016
+ const { args: authArgs, credentialType } = notarytoolCredentialArgs(env);
1017
+ const startedAtMs = Date.now();
1018
+ const artifactName = path.basename(filePath);
1019
+ const result = runResult('xcrun', [
1020
+ 'notarytool',
1021
+ 'submit',
1022
+ filePath,
1023
+ ...authArgs,
1024
+ '--no-progress',
1025
+ '--output-format',
1026
+ 'json',
1027
+ ], {
1028
+ cwd,
1029
+ env,
1030
+ maxBuffer: 10 * 1024 * 1024,
1031
+ });
1032
+
1033
+ let submissionId = '';
1034
+ if (cleanString(result.stdout)) {
1035
+ try {
1036
+ submissionId = parseNotarySubmissionId(parseJsonOutput(result.stdout, 'notarytool submit'));
1037
+ } catch {
1038
+ submissionId = '';
1039
+ }
1040
+ }
1041
+ if (!submissionId) {
1042
+ submissionId = parseNotarySubmissionId(waitForNotarySubmission(artifactName, startedAtMs, cwd, env, authArgs));
1043
+ }
1044
+ if (!submissionId) {
1045
+ throw new Error(`Failed to resolve notary submission id for ${artifactName}.`);
1046
+ }
1047
+ if (result.status !== 0) {
1048
+ console.warn(`notarytool submit exited with code ${result.status} for ${artifactName}; continuing with submission ${submissionId}.`);
1049
+ }
1050
+
1051
+ const info = waitForNotaryDecision(submissionId, artifactName, cwd, env, authArgs);
1052
+ return {
1053
+ id: submissionId,
1054
+ status: parseNotaryStatus(info),
1055
+ credentialType,
1056
+ info,
1057
+ };
1058
+ }
1059
+
1060
+ function manualNotarizeDesktopArtifacts({ engineDir, lane, env, artifactPaths, channelPath }) {
1061
+ const appPath = desktopAppBundlePath(engineDir, lane);
1062
+ const zipPath = releaseArtifactPath(artifactPaths, '.zip');
1063
+ const dmgPath = releaseArtifactPath(artifactPaths, '.dmg');
1064
+
1065
+ run('codesign', ['--verify', '--deep', '--strict', appPath], { cwd: engineDir, env });
1066
+ run('hdiutil', ['verify', dmgPath], { cwd: engineDir, env, maxBuffer: 10 * 1024 * 1024 });
1067
+
1068
+ run('codesign', [
1069
+ '--force',
1070
+ '--sign',
1071
+ codesignIdentityForCli(env),
1072
+ '--timestamp',
1073
+ dmgPath,
1074
+ ], {
1075
+ cwd: engineDir,
1076
+ env,
1077
+ });
1078
+ run('hdiutil', ['verify', dmgPath], { cwd: engineDir, env, maxBuffer: 10 * 1024 * 1024 });
1079
+ const dmgNotarization = submitArtifactForNotarization(dmgPath, engineDir, env);
1080
+ run('xcrun', ['stapler', 'staple', appPath], { cwd: engineDir, env });
1081
+ run('xcrun', ['stapler', 'validate', appPath], { cwd: engineDir, env });
1082
+ run('spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath], { cwd: engineDir, env });
1083
+ run('xcrun', ['stapler', 'staple', dmgPath], { cwd: engineDir, env });
1084
+ run('xcrun', ['stapler', 'validate', dmgPath], { cwd: engineDir, env });
1085
+ run('spctl', ['-a', '-vvv', '-t', 'install', dmgPath], { cwd: engineDir, env });
1086
+ const dmgBlockmapPath = regenerateArtifactBlockmap(engineDir, dmgPath, env);
1087
+ recreateZipFromAppBundle(engineDir, appPath, zipPath, env);
1088
+ const zipBlockmapPath = regenerateArtifactBlockmap(engineDir, zipPath, env);
1089
+
1090
+ updateChannelArtifactDigest(channelPath, zipPath);
1091
+ updateChannelArtifactDigest(channelPath, dmgPath);
1092
+
1093
+ return {
1094
+ enabled: true,
1095
+ appPath,
1096
+ zipPath,
1097
+ dmgPath,
1098
+ dmgBlockmapPath,
1099
+ zipBlockmapPath,
1100
+ dmgSubmissionId: dmgNotarization.id,
1101
+ };
1102
+ }
1103
+
703
1104
  function githubReleaseId(tag, cwd, env, releaseRepository = desktopReleaseRepository(env)) {
704
1105
  try {
705
1106
  const publishedReleaseId = cleanString(run('gh', [
@@ -1261,6 +1662,7 @@ function publishDesktopRelease(request, options = {}) {
1261
1662
  flyApp: publishTarget.flyApp || '',
1262
1663
  flyRemoteDir: publishTarget.remoteDir || '',
1263
1664
  releaseRepository: releaseRepository.fullName,
1665
+ manualNotarization: manualDesktopNotarizationEnabled(releaseEnv),
1264
1666
  legacyBridge: desktopLegacyBridgePlan(releaseEnv, publishTarget),
1265
1667
  };
1266
1668
  }
@@ -1299,10 +1701,19 @@ function publishDesktopRelease(request, options = {}) {
1299
1701
  run('git', ['push', 'origin', `refs/tags/v${version}`, '--force'], { cwd: engineDir });
1300
1702
  run('npm', ['version', '--no-git-tag-version', '--allow-same-version', version], { cwd: engineDir, env: releaseEnv });
1301
1703
  run('npm', ['run', 'compile'], { cwd: engineDir, env: releaseEnv });
1302
- run('npm', ['run', 'npm:sync'], { cwd: engineDir, env: releaseEnv });
1303
- copyUiIntoEngine(uiDir, engineDir);
1304
- const appOrigin = writeBundledWebEnv(engineDir, request.lane, releaseEnv);
1305
- run('npm', ['run', 'build:next'], { cwd: engineDir, env: releaseEnv });
1704
+ const thinRuntimeBuild = desktopThinRuntimeBuildEnabled(releaseEnv);
1705
+ let appOrigin = appOriginForLane(request.lane, releaseEnv);
1706
+ if (thinRuntimeBuild) {
1707
+ if (!cleanString(releaseEnv.NEXT_PUBLIC_SUPABASE_URL)) {
1708
+ throw new Error('Missing NEXT_PUBLIC_SUPABASE_URL for thin desktop auth metadata.');
1709
+ }
1710
+ console.log('Building thin hosted-UI desktop shell; skipping bundled runtime and Next.js renderer payload.');
1711
+ } else {
1712
+ run('npm', ['run', 'npm:sync'], { cwd: engineDir, env: releaseEnv });
1713
+ copyUiIntoEngine(uiDir, engineDir);
1714
+ appOrigin = writeBundledWebEnv(engineDir, request.lane, releaseEnv);
1715
+ run('npm', ['run', 'build:next'], { cwd: engineDir, env: releaseEnv });
1716
+ }
1306
1717
  if (request.lane === 'preview') run('npm', ['run', 'assets:preview-icon'], { cwd: engineDir, env: releaseEnv });
1307
1718
 
1308
1719
  const buildEnv = envForBuild(releaseEnv, request, sourcePackageVersion, version, refs, shas, appOrigin, releaseRepository, publishTarget);
@@ -1319,6 +1730,15 @@ function publishDesktopRelease(request, options = {}) {
1319
1730
  const tag = `v${version}`;
1320
1731
  const artifacts = desktopReleaseArtifactPaths(path.join(engineDir, 'build'), request.lane, version);
1321
1732
  const channelPath = artifacts.find((filePath) => filePath.endsWith('-mac.yml'));
1733
+ const manualNotarization = manualDesktopNotarizationEnabled(releaseEnv)
1734
+ ? manualNotarizeDesktopArtifacts({
1735
+ engineDir,
1736
+ lane: request.lane,
1737
+ env: buildEnv,
1738
+ artifactPaths: artifacts,
1739
+ channelPath,
1740
+ })
1741
+ : { enabled: false };
1322
1742
  let releaseUrl = '';
1323
1743
  let legacyBridge = null;
1324
1744
  if (publishTarget.provider === 'github') {
@@ -1358,6 +1778,7 @@ function publishDesktopRelease(request, options = {}) {
1358
1778
  flyApp: publishTarget.flyApp || '',
1359
1779
  flyRemoteDir: publishTarget.remoteDir || '',
1360
1780
  releaseRepository: releaseRepository.fullName,
1781
+ manualNotarization,
1361
1782
  legacyBridge,
1362
1783
  artifacts: artifacts.map((filePath) => path.basename(filePath)),
1363
1784
  repository: request.repository,
@@ -1387,7 +1808,10 @@ if (require.main === module) {
1387
1808
  module.exports = {
1388
1809
  absoluteAssetUrl,
1389
1810
  applyDesktopReleaseEnv,
1811
+ appBuilderBinaryPath,
1812
+ appBuilderBinaryRelativePath,
1390
1813
  bridgeChannelInfoForBase,
1814
+ codesignIdentityForCli,
1391
1815
  buildNumberFromNow,
1392
1816
  desktopLegacyBridgeEnabled,
1393
1817
  desktopLegacyBridgePlan,
@@ -1400,10 +1824,13 @@ module.exports = {
1400
1824
  desktopVersion,
1401
1825
  desktopReleaseArtifactPaths,
1402
1826
  envForBuild,
1827
+ manualDesktopNotarizationEnabled,
1828
+ notarytoolCredentialArgs,
1403
1829
  parseEnvFile,
1404
1830
  parseUploadOffsetMismatch,
1405
1831
  publishLegacyGithubBridgeRelease,
1406
1832
  publishDesktopRelease,
1833
+ recreateZipFromAppBundle,
1407
1834
  recoverableUploadOffset,
1408
1835
  requestFromEnv,
1409
1836
  shouldHandleRequest,
@@ -239,7 +239,7 @@ test('mixed bundle carries automations and apps with heads and installs everythi
239
239
  test('legacy agent-only bundles still validate and empty bundles are rejected', () => {
240
240
  assert.throws(
241
241
  () => validateBundle({ kind: 'amalgm.bundle', schemaVersion: 1 }),
242
- /at least one agent, automation, or app/,
242
+ /at least one agent, automation, app, or tool/,
243
243
  );
244
244
  assert.throws(
245
245
  () => validateBundle({ kind: 'something.else', schemaVersion: 1 }),
@@ -247,6 +247,110 @@ test('legacy agent-only bundles still validate and empty bundles are rejected',
247
247
  );
248
248
  });
249
249
 
250
+ test('standalone tool bundle carries the tool as a head and installs it', async () => {
251
+ const { defaultToolboxService } = require('../toolbox/service');
252
+ defaultToolboxService.registerTool({
253
+ id: 'acme.standalone',
254
+ name: 'Acme Standalone',
255
+ description: 'A standalone shareable tool',
256
+ type: 'cli',
257
+ origin: 'user',
258
+ source: { command: 'acme-standalone', inputMode: 'json-stdin', outputMode: 'json' },
259
+ actions: [{
260
+ id: 'acme.standalone.run',
261
+ name: 'run',
262
+ description: 'Run it.',
263
+ inputSchema: { type: 'object' },
264
+ }],
265
+ });
266
+
267
+ const { bundle, preview } = createBundle({ toolIds: ['acme.standalone'] });
268
+
269
+ assert.equal(bundle.kind, 'amalgm.bundle');
270
+ assert.equal(bundle.title, 'Acme Standalone');
271
+ assert.deepEqual(bundle.heads, [{ type: 'tool', id: 'acme.standalone' }]);
272
+ assert.deepEqual(bundle.headIds, ['tool:acme.standalone']);
273
+ assert.equal(bundle.tools.length, 1);
274
+ assert.equal(bundle.toolActions.length, 1);
275
+ const toolElement = bundle.elements.find((element) => element.id === 'tool:acme.standalone');
276
+ assert.equal(toolElement.label, 'Acme Standalone');
277
+ const actionElement = bundle.elements.find((element) => element.type === 'tool_action');
278
+ assert.equal(actionElement.data.id, 'acme.standalone.run');
279
+ assert.equal(preview.tools.length, 1);
280
+
281
+ const revived = validateBundle(JSON.parse(JSON.stringify(bundle)));
282
+ assert.deepEqual(revived.headIds, ['tool:acme.standalone']);
283
+
284
+ defaultToolboxService.deleteTool('acme.standalone');
285
+ const result = await installBundle(JSON.parse(JSON.stringify(bundle)));
286
+ assert.equal(result.ok, true);
287
+ assert.equal(result.installedTools.length, 1);
288
+ assert.equal(result.installedTools[0].id, 'acme.standalone');
289
+ const catalog = defaultToolboxService.readCatalog();
290
+ assert.equal(catalog.tools['acme.standalone'].name, 'Acme Standalone');
291
+ assert.equal(Boolean(catalog.toolActions['acme.standalone.run']), true);
292
+ });
293
+
294
+ test('unknown standalone tool ids are rejected at export', () => {
295
+ assert.throws(
296
+ () => createBundle({ toolIds: ['does.not.exist'] }),
297
+ /Tool not found: does\.not\.exist/,
298
+ );
299
+ });
300
+
301
+ test('tools shared by multiple agents and standalone stay canonical (one record per id)', () => {
302
+ const { createAgent } = require('../agents/store');
303
+ const { upsertAgentConfig } = require('../agent-config/store');
304
+ const { defaultToolboxService } = require('../toolbox/service');
305
+
306
+ defaultToolboxService.registerTool({
307
+ id: 'acme.shared',
308
+ name: 'Acme Shared',
309
+ type: 'cli',
310
+ origin: 'user',
311
+ source: { command: 'acme-shared', inputMode: 'json-stdin', outputMode: 'json' },
312
+ actions: [{ id: 'acme.shared.run', name: 'run', inputSchema: { type: 'object' } }],
313
+ });
314
+
315
+ const left = createAgent({
316
+ id: 'dedupe-left',
317
+ name: 'Dedupe Left',
318
+ baseHarnessId: 'codex',
319
+ loadout: { toolIds: ['acme.shared'] },
320
+ authMethod: 'amalgm',
321
+ });
322
+ upsertAgentConfig(left.id, { instructions: 'left', loadout: { toolIds: ['acme.shared'] } });
323
+ const right = createAgent({
324
+ id: 'dedupe-right',
325
+ name: 'Dedupe Right',
326
+ baseHarnessId: 'codex',
327
+ loadout: { toolIds: ['acme.shared'] },
328
+ authMethod: 'amalgm',
329
+ });
330
+ upsertAgentConfig(right.id, { instructions: 'right', loadout: { toolIds: ['acme.shared'] } });
331
+
332
+ const { bundle } = createBundle({
333
+ agentIds: [left.id, right.id],
334
+ toolIds: ['acme.shared'],
335
+ });
336
+
337
+ // One canonical tool record and element; both agents point edges at it.
338
+ assert.equal(bundle.tools.filter((tool) => tool.id === 'acme.shared').length, 1);
339
+ assert.equal(
340
+ bundle.elements.filter((element) => element.id === 'tool:acme.shared').length,
341
+ 1,
342
+ );
343
+ const usesToolEdges = bundle.edges.filter(
344
+ (edge) => edge.to === 'tool:acme.shared' && edge.kind === 'uses-tool',
345
+ );
346
+ assert.equal(usesToolEdges.length, 2);
347
+ assert.deepEqual(bundle.heads, [
348
+ { type: 'agent', id: left.id },
349
+ { type: 'agent', id: right.id },
350
+ { type: 'tool', id: 'acme.shared' },
351
+ ]);
352
+ });
353
+
250
354
  test('rest handlers round-trip a share: preview exports a bundle and install recreates it', async () => {
251
355
  const { handlePreview, handleInstall } = require('../agent-bundles/rest');
252
356
  const automation = createAutomation({
@@ -5,12 +5,16 @@ const assert = require('node:assert/strict');
5
5
  const fs = require('fs');
6
6
  const os = require('os');
7
7
  const path = require('path');
8
+ const { spawnSync } = require('child_process');
8
9
  const yaml = require('js-yaml');
9
10
 
10
11
  const {
11
12
  absoluteAssetUrl,
12
13
  applyDesktopReleaseEnv,
14
+ appBuilderBinaryPath,
15
+ appBuilderBinaryRelativePath,
13
16
  bridgeChannelInfoForBase,
17
+ codesignIdentityForCli,
14
18
  desktopLegacyBridgePlan,
15
19
  desktopLegacyBridgeRepository,
16
20
  desktopReleaseArtifactPaths,
@@ -21,9 +25,12 @@ const {
21
25
  desktopUpdateUrlForLane,
22
26
  desktopVersion,
23
27
  envForBuild,
28
+ manualDesktopNotarizationEnabled,
29
+ notarytoolCredentialArgs,
24
30
  parseEnvFile,
25
31
  parseUploadOffsetMismatch,
26
32
  publishDesktopRelease,
33
+ recreateZipFromAppBundle,
27
34
  recoverableUploadOffset,
28
35
  requestFromEnv,
29
36
  shouldHandleRequest,
@@ -205,6 +212,24 @@ test('desktop release runner uses electron-builder compatible signing identity n
205
212
  assert.equal(env.AMALGM_DESKTOP_RELEASE_FLY_APP, 'amalgm-desktop-releases');
206
213
  assert.equal(env.AMALGM_DESKTOP_RELEASE_FLY_ROOT, '/data');
207
214
  assert.equal(env.AMALGM_DESKTOP_THIN, '1');
215
+ assert.equal(env.AMALGM_DESKTOP_NOTARIZE, 'off');
216
+ });
217
+
218
+ test('desktop release runner preserves explicit bundled desktop builds', () => {
219
+ const env = envForBuild(
220
+ {
221
+ GH_TOKEN: 'token',
222
+ AMALGM_DESKTOP_THIN: '0',
223
+ },
224
+ { lane: 'main', delivery: 'delivery-id' },
225
+ '0.1.124',
226
+ '0.1.124000001',
227
+ { engineRef: 'engine', uiRef: 'ui' },
228
+ { engineSha: 'engine-sha', uiSha: 'ui-sha' },
229
+ 'https://amalgm.ai',
230
+ );
231
+
232
+ assert.equal(env.AMALGM_DESKTOP_THIN, '0');
208
233
  });
209
234
 
210
235
  test('desktop release runner omits blank certificate file secrets', () => {
@@ -226,6 +251,120 @@ test('desktop release runner omits blank certificate file secrets', () => {
226
251
  assert.equal(env.CSC_NAME, 'amalgm, Inc. (C5S6UATV3L)');
227
252
  });
228
253
 
254
+ test('desktop release runner manual notarization can be disabled explicitly', () => {
255
+ assert.equal(manualDesktopNotarizationEnabled({}), true);
256
+ assert.equal(manualDesktopNotarizationEnabled({
257
+ AMALGM_DESKTOP_RELEASE_MANUAL_NOTARIZE: '0',
258
+ }), false);
259
+
260
+ const env = envForBuild(
261
+ {
262
+ GH_TOKEN: 'token',
263
+ AMALGM_DESKTOP_RELEASE_MANUAL_NOTARIZE: '0',
264
+ AMALGM_DESKTOP_NOTARIZE: 'required',
265
+ },
266
+ { lane: 'main', delivery: 'delivery-id' },
267
+ '0.1.124',
268
+ '0.1.124000001',
269
+ { engineRef: 'engine', uiRef: 'ui' },
270
+ { engineSha: 'engine-sha', uiSha: 'ui-sha' },
271
+ 'https://amalgm.ai',
272
+ );
273
+ assert.equal(env.AMALGM_DESKTOP_NOTARIZE, 'required');
274
+ });
275
+
276
+ test('desktop release runner resolves manual notarization credentials and CLI identity', () => {
277
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-desktop-release-notary-'));
278
+ const keyPath = path.join(tempRoot, 'AuthKey_TEST.p8');
279
+ fs.writeFileSync(keyPath, 'dummy-key');
280
+
281
+ try {
282
+ assert.deepEqual(notarytoolCredentialArgs({
283
+ APPLE_API_KEY: keyPath,
284
+ APPLE_API_KEY_ID: 'KEYID123',
285
+ APPLE_API_ISSUER: 'issuer-123',
286
+ }), {
287
+ args: ['--key', keyPath, '--key-id', 'KEYID123', '--issuer', 'issuer-123'],
288
+ credentialType: 'api-key',
289
+ });
290
+
291
+ assert.deepEqual(notarytoolCredentialArgs({
292
+ APPLE_ID: 'user@example.com',
293
+ APPLE_APP_SPECIFIC_PASSWORD: 'app-password',
294
+ APPLE_TEAM_ID: 'TEAM123',
295
+ }), {
296
+ args: ['--apple-id', 'user@example.com', '--password', 'app-password', '--team-id', 'TEAM123'],
297
+ credentialType: 'apple-id',
298
+ });
299
+
300
+ assert.equal(
301
+ codesignIdentityForCli({ CSC_NAME: 'amalgm, Inc. (C5S6UATV3L)' }),
302
+ 'Developer ID Application: amalgm, Inc. (C5S6UATV3L)',
303
+ );
304
+ assert.equal(
305
+ codesignIdentityForCli({ CSC_NAME: 'Developer ID Application: amalgm, Inc. (C5S6UATV3L)' }),
306
+ 'Developer ID Application: amalgm, Inc. (C5S6UATV3L)',
307
+ );
308
+ assert.equal(
309
+ appBuilderBinaryRelativePath('darwin', 'arm64'),
310
+ path.join('mac', 'app-builder_arm64'),
311
+ );
312
+ assert.equal(
313
+ appBuilderBinaryRelativePath('darwin', 'x64'),
314
+ path.join('mac', 'app-builder_amd64'),
315
+ );
316
+ assert.equal(
317
+ appBuilderBinaryRelativePath('linux', 'arm64'),
318
+ path.join('linux', 'arm64', 'app-builder'),
319
+ );
320
+ } finally {
321
+ fs.rmSync(tempRoot, { recursive: true, force: true });
322
+ }
323
+ });
324
+
325
+ test('desktop release runner resolves app-builder binary path from checkout node_modules or override', () => {
326
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-desktop-release-app-builder-'));
327
+ const engineDir = path.join(tempRoot, 'engine');
328
+ const binaryPath = path.join(engineDir, 'node_modules', 'app-builder-bin', 'mac', 'app-builder_arm64');
329
+ fs.mkdirSync(path.dirname(binaryPath), { recursive: true });
330
+ fs.writeFileSync(binaryPath, '');
331
+
332
+ try {
333
+ assert.equal(
334
+ appBuilderBinaryPath(engineDir, {}, 'darwin', 'arm64'),
335
+ binaryPath,
336
+ );
337
+ assert.equal(
338
+ appBuilderBinaryPath(engineDir, { AMALGM_DESKTOP_APP_BUILDER_BIN: '/tmp/custom-app-builder' }, 'darwin', 'arm64'),
339
+ '/tmp/custom-app-builder',
340
+ );
341
+ } finally {
342
+ fs.rmSync(tempRoot, { recursive: true, force: true });
343
+ }
344
+ });
345
+
346
+ test('desktop release runner recreates updater zip from app bundle', () => {
347
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-desktop-release-zip-'));
348
+ const engineDir = path.join(tempRoot, 'engine');
349
+ const appPath = path.join(engineDir, 'build', 'mac-arm64', 'amalgm-preview.app');
350
+ const markerPath = path.join(appPath, 'Contents', 'Resources', 'marker.txt');
351
+ const zipPath = path.join(engineDir, 'build', 'amalgm-preview-0.1.1-arm64.zip');
352
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
353
+ fs.writeFileSync(markerPath, 'fresh app payload');
354
+ fs.writeFileSync(zipPath, 'old zip payload');
355
+
356
+ try {
357
+ recreateZipFromAppBundle(engineDir, appPath, zipPath, {});
358
+ assert.ok(fs.statSync(zipPath).size > 'old zip payload'.length);
359
+
360
+ const listing = spawnSync('unzip', ['-l', zipPath], { encoding: 'utf8' });
361
+ assert.equal(listing.status, 0, listing.stderr);
362
+ assert.match(listing.stdout, /amalgm-preview\.app\/Contents\/Resources\/marker\.txt/);
363
+ } finally {
364
+ fs.rmSync(tempRoot, { recursive: true, force: true });
365
+ }
366
+ });
367
+
229
368
  test('desktop release repository can be overridden as owner/repo or full name', () => {
230
369
  assert.deepEqual(desktopReleaseRepository({
231
370
  AMALGM_DESKTOP_UPDATE_OWNER: 'example',