amalgm 0.1.135 → 0.1.136
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 +1 -1
- package/runtime/lib/harnesses.js +61 -162
- package/runtime/scripts/amalgm-mcp/agent-bundles/app-entries.js +124 -0
- package/runtime/scripts/amalgm-mcp/agent-bundles/app-files.js +160 -0
- package/runtime/scripts/amalgm-mcp/agent-bundles/automation-entries.js +186 -0
- package/runtime/scripts/amalgm-mcp/agent-bundles/bundles.js +272 -32
- package/runtime/scripts/amalgm-mcp/agent-bundles/rest.js +25 -8
- package/runtime/scripts/amalgm-mcp/automations/rest.js +21 -11
- package/runtime/scripts/amalgm-mcp/automations/store.js +68 -1
- package/runtime/scripts/amalgm-mcp/automations/tools.js +13 -4
- package/runtime/scripts/amalgm-mcp/events/desktop-release-runner.js +1 -0
- package/runtime/scripts/amalgm-mcp/lib/email-deeplinks.js +117 -0
- package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +42 -19
- package/runtime/scripts/amalgm-mcp/lib/email-md.js +24 -17
- package/runtime/scripts/amalgm-mcp/notify/index.js +6 -2
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -3
- package/runtime/scripts/amalgm-mcp/tests/agent-bundles.test.js +8 -8
- package/runtime/scripts/amalgm-mcp/tests/bundle-entries.test.js +288 -0
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-automation-seed.test.js +37 -1
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-runner.test.js +22 -0
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-server.test.js +15 -5
- package/runtime/scripts/amalgm-mcp/tests/email-embeds.test.js +35 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +1 -0
- package/runtime/scripts/chat-core/adapters/acp-client.js +156 -0
- package/runtime/scripts/chat-core/adapters/cursor.js +254 -0
- package/runtime/scripts/chat-core/adapters/pi.js +302 -0
- package/runtime/scripts/chat-core/auth.js +20 -3
- package/runtime/scripts/chat-core/chat-payload.js +2 -0
- package/runtime/scripts/chat-core/contract.js +56 -1
- package/runtime/scripts/chat-core/normalizers/cursor.js +174 -0
- package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +38 -0
- package/runtime/scripts/chat-core/normalizers/pi.js +143 -0
- package/runtime/scripts/chat-core/server.js +4 -0
- package/runtime/scripts/chat-core/tests/cursor.test.js +178 -0
- package/runtime/scripts/chat-core/tests/pi.test.js +182 -0
- package/runtime/scripts/chat-core/tooling/native-config.js +38 -0
- package/runtime/scripts/chat-core/usage.js +20 -0
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
const embeds = require('./email-embeds');
|
|
21
|
+
const { buildEmailRenderContext, normalizeEmailLink } = require('./email-deeplinks');
|
|
21
22
|
|
|
22
23
|
const EMAIL_TOKENS = {
|
|
23
24
|
font: {
|
|
@@ -86,7 +87,7 @@ function renderAmalgmCitation(label, _kindAndPath, description) {
|
|
|
86
87
|
/**
|
|
87
88
|
* Inline format: bold, italic, code, links, images, amalgm:// citations.
|
|
88
89
|
*/
|
|
89
|
-
function inlineFormat(text) {
|
|
90
|
+
function inlineFormat(text, renderContext = {}) {
|
|
90
91
|
return (
|
|
91
92
|
text
|
|
92
93
|
// Media mid-sentence: images stay inline; video becomes a play link;
|
|
@@ -103,7 +104,8 @@ function inlineFormat(text) {
|
|
|
103
104
|
return `<span style="color:${EMAIL_TOKENS.color.link}">${escapeHtmlText(alt || embeds.mediaNameOf(url) || 'file')}</span>`;
|
|
104
105
|
}
|
|
105
106
|
if (embeds.mediaKindOf(url) === 'video') {
|
|
106
|
-
|
|
107
|
+
const href = normalizeEmailLink(url, renderContext.appOrigin) || url;
|
|
108
|
+
return `<a href="${escapeHtmlAttr(href)}" target="_blank" style="color:${EMAIL_TOKENS.color.link};text-decoration:none">▶ ${escapeHtmlText(alt || 'video')}</a>`;
|
|
107
109
|
}
|
|
108
110
|
return `<img src="${escapeHtmlAttr(url)}" alt="${escapeHtmlAttr(alt)}" style="max-width:100%;height:auto;border-radius:16px;margin:8px 0">`;
|
|
109
111
|
},
|
|
@@ -111,7 +113,7 @@ function inlineFormat(text) {
|
|
|
111
113
|
// Entity renders mid-sentence collapse to the compact tile.
|
|
112
114
|
.replace(
|
|
113
115
|
/\[([^\]]+)\]\((tile|full):([a-zA-Z]+):([^)\s]*)\)/g,
|
|
114
|
-
(_, label,
|
|
116
|
+
(_, label, _mode, type, id) => embeds.renderEntityEmbed('tile', type, label, id, renderContext),
|
|
115
117
|
)
|
|
116
118
|
// amalgm:// citations (run before generic link regex so the optional
|
|
117
119
|
// title attribute doesn't get swallowed into the href). Format:
|
|
@@ -125,7 +127,10 @@ function inlineFormat(text) {
|
|
|
125
127
|
// Links: [text](url) — blue hyperlink per the rendering ruleset.
|
|
126
128
|
.replace(
|
|
127
129
|
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
128
|
-
|
|
130
|
+
(_, label, url) => {
|
|
131
|
+
const href = normalizeEmailLink(url, renderContext.appOrigin) || url;
|
|
132
|
+
return `<a href="${escapeHtmlAttr(href)}" style="color:${EMAIL_TOKENS.color.link};text-decoration:none" target="_blank">${escapeHtmlText(label)}</a>`;
|
|
133
|
+
},
|
|
129
134
|
)
|
|
130
135
|
// Bold + italic: ***text***
|
|
131
136
|
.replace(
|
|
@@ -145,7 +150,8 @@ function inlineFormat(text) {
|
|
|
145
150
|
);
|
|
146
151
|
}
|
|
147
152
|
|
|
148
|
-
function markdownToEmailHtml(md) {
|
|
153
|
+
function markdownToEmailHtml(md, renderContextInput = {}) {
|
|
154
|
+
const renderContext = buildEmailRenderContext(renderContextInput);
|
|
149
155
|
let text = md.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
150
156
|
// Normalize literal \n sequences (LLMs sometimes send \\n in JSON).
|
|
151
157
|
text = text.replace(/\\n/g, '\n');
|
|
@@ -172,7 +178,7 @@ function markdownToEmailHtml(md) {
|
|
|
172
178
|
|
|
173
179
|
function flushPara() {
|
|
174
180
|
if (!paraLines.length) return;
|
|
175
|
-
const content = inlineFormat(paraLines.join('\n')).replace(/\n/g, '<br>');
|
|
181
|
+
const content = inlineFormat(paraLines.join('\n'), renderContext).replace(/\n/g, '<br>');
|
|
176
182
|
out.push(`<p style="margin:12px 0;line-height:1.7;color:#ffffff">${content}</p>`);
|
|
177
183
|
paraLines = [];
|
|
178
184
|
}
|
|
@@ -180,7 +186,7 @@ function markdownToEmailHtml(md) {
|
|
|
180
186
|
if (!listItems.length) return;
|
|
181
187
|
const tag = listType;
|
|
182
188
|
const items = listItems
|
|
183
|
-
.map((t) => `<li style="margin:4px 0;color:#ffffff">${inlineFormat(t)}</li>`)
|
|
189
|
+
.map((t) => `<li style="margin:4px 0;color:#ffffff">${inlineFormat(t, renderContext)}</li>`)
|
|
184
190
|
.join('');
|
|
185
191
|
out.push(
|
|
186
192
|
`<${tag} style="margin:12px 0;padding-left:24px;color:#ffffff">${items}</${tag}>`,
|
|
@@ -207,7 +213,7 @@ function markdownToEmailHtml(md) {
|
|
|
207
213
|
const hdr = headerCells
|
|
208
214
|
.map(
|
|
209
215
|
(c, i) =>
|
|
210
|
-
`<th style="${cellBase};${cellBorders(i === headerCells.length - 1, false)}background:${EMAIL_TOKENS.color.tableHeaderBg};font-weight:500;color:#ffffff">${inlineFormat(c)}</th>`,
|
|
216
|
+
`<th style="${cellBase};${cellBorders(i === headerCells.length - 1, false)}background:${EMAIL_TOKENS.color.tableHeaderBg};font-weight:500;color:#ffffff">${inlineFormat(c, renderContext)}</th>`,
|
|
211
217
|
)
|
|
212
218
|
.join('');
|
|
213
219
|
const body = bodyRows
|
|
@@ -217,7 +223,7 @@ function markdownToEmailHtml(md) {
|
|
|
217
223
|
r.cells
|
|
218
224
|
.map(
|
|
219
225
|
(c, i) =>
|
|
220
|
-
`<td style="${cellBase};${cellBorders(i === r.cells.length - 1, rowIdx === bodyRows.length - 1)}color:#ffffff">${inlineFormat(c)}</td>`,
|
|
226
|
+
`<td style="${cellBase};${cellBorders(i === r.cells.length - 1, rowIdx === bodyRows.length - 1)}color:#ffffff">${inlineFormat(c, renderContext)}</td>`,
|
|
221
227
|
)
|
|
222
228
|
.join('') +
|
|
223
229
|
'</tr>',
|
|
@@ -230,7 +236,7 @@ function markdownToEmailHtml(md) {
|
|
|
230
236
|
}
|
|
231
237
|
function flushQuote() {
|
|
232
238
|
if (!quoteLines.length) return;
|
|
233
|
-
const content = inlineFormat(quoteLines.join('\n')).replace(/\n/g, '<br>');
|
|
239
|
+
const content = inlineFormat(quoteLines.join('\n'), renderContext).replace(/\n/g, '<br>');
|
|
234
240
|
out.push(
|
|
235
241
|
`<blockquote style="margin:16px 0;padding:8px 16px;border-left:3px solid ${EMAIL_TOKENS.color.tableBorder};color:${EMAIL_TOKENS.color.textSecondary};font-style:italic">${content}</blockquote>`,
|
|
236
242
|
);
|
|
@@ -275,14 +281,14 @@ function markdownToEmailHtml(md) {
|
|
|
275
281
|
const mediaMatch = parseStandaloneMediaLine(trimmed);
|
|
276
282
|
if (mediaMatch) {
|
|
277
283
|
flushAll();
|
|
278
|
-
out.push(embeds.renderMediaEmbed(mediaMatch.alt, mediaMatch.src, mediaMatch.title || null));
|
|
284
|
+
out.push(embeds.renderMediaEmbed(mediaMatch.alt, mediaMatch.src, mediaMatch.title || null, renderContext));
|
|
279
285
|
continue;
|
|
280
286
|
}
|
|
281
287
|
// Standalone entity render → tile card or expanded card.
|
|
282
288
|
const entityMatch = trimmed.match(/^\[([^\]]+)\]\((tile|full):([a-zA-Z]+):([^)\s]*)\)$/);
|
|
283
289
|
if (entityMatch) {
|
|
284
290
|
flushAll();
|
|
285
|
-
out.push(embeds.renderEntityEmbed(entityMatch[2], entityMatch[3], entityMatch[1]));
|
|
291
|
+
out.push(embeds.renderEntityEmbed(entityMatch[2], entityMatch[3], entityMatch[1], entityMatch[4], renderContext));
|
|
286
292
|
continue;
|
|
287
293
|
}
|
|
288
294
|
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
|
@@ -291,7 +297,7 @@ function markdownToEmailHtml(md) {
|
|
|
291
297
|
const level = headingMatch[1].length;
|
|
292
298
|
const size = EMAIL_TOKENS.type.headings[level];
|
|
293
299
|
out.push(
|
|
294
|
-
`<h${level} style="margin:20px 0 8px;font-size:${size}px;font-weight:650;color:#ffffff;line-height:1.35">${inlineFormat(headingMatch[2])}</h${level}>`,
|
|
300
|
+
`<h${level} style="margin:20px 0 8px;font-size:${size}px;font-weight:650;color:#ffffff;line-height:1.35">${inlineFormat(headingMatch[2], renderContext)}</h${level}>`,
|
|
295
301
|
);
|
|
296
302
|
continue;
|
|
297
303
|
}
|
|
@@ -349,10 +355,11 @@ function markdownToEmailHtml(md) {
|
|
|
349
355
|
/**
|
|
350
356
|
* Wrap the converted markdown body in a full notification email shell.
|
|
351
357
|
*/
|
|
352
|
-
function formatNotificationEmail(message, link) {
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
|
|
358
|
+
function formatNotificationEmail(message, link, renderContextInput = {}) {
|
|
359
|
+
const renderContext = buildEmailRenderContext({ ...renderContextInput, link });
|
|
360
|
+
const htmlMessage = markdownToEmailHtml(message, renderContext);
|
|
361
|
+
const linkHtml = renderContext.detailsUrl
|
|
362
|
+
? `<p style="margin:24px 0 0"><a href="${escapeHtmlAttr(renderContext.detailsUrl)}" style="color:${EMAIL_TOKENS.color.link};text-decoration:none;font-size:14px">View details →</a></p>`
|
|
356
363
|
: '';
|
|
357
364
|
return `<!DOCTYPE html>
|
|
358
365
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
@@ -32,7 +32,7 @@ async function notifyUserViaProxy({ subject: customSubject, message, link, sessi
|
|
|
32
32
|
// Local media embeds become attachments riding inside the email: images
|
|
33
33
|
// inline via cid:, videos as regular attachments with a poster card.
|
|
34
34
|
const media = prepareEmailMedia(message);
|
|
35
|
-
const html = formatNotificationEmail(media.message, link || null);
|
|
35
|
+
const html = formatNotificationEmail(media.message, link || null, { sessionId });
|
|
36
36
|
const attachments = [
|
|
37
37
|
...media.attachments,
|
|
38
38
|
...prepareEmailIconAttachments(html, media.attachments),
|
|
@@ -92,7 +92,11 @@ module.exports = [
|
|
|
92
92
|
enum: ['info', 'warning', 'error', 'success'],
|
|
93
93
|
description: 'Severity level. Defaults to info.',
|
|
94
94
|
},
|
|
95
|
-
link: {
|
|
95
|
+
link: {
|
|
96
|
+
type: 'string',
|
|
97
|
+
description:
|
|
98
|
+
'Optional URL for details. This also becomes the fallback click target for local email embeds when a more specific deep link is not available.',
|
|
99
|
+
},
|
|
96
100
|
},
|
|
97
101
|
required: ['message'],
|
|
98
102
|
},
|
|
@@ -13,10 +13,10 @@ function normalizeResources(resources) {
|
|
|
13
13
|
function readResource(resource, cache) {
|
|
14
14
|
switch (resource) {
|
|
15
15
|
case 'automations':
|
|
16
|
-
cache.automations ||= require('../automations/store').
|
|
16
|
+
cache.automations ||= require('../automations/store').listPublicAutomations();
|
|
17
17
|
return cache.automations;
|
|
18
18
|
case 'triggers':
|
|
19
|
-
cache.triggers ||= require('../automations/store').
|
|
19
|
+
cache.triggers ||= require('../automations/store').listPublicTriggers();
|
|
20
20
|
return cache.triggers;
|
|
21
21
|
case 'automation_runs':
|
|
22
22
|
return require('../automations/store').listAutomationRuns({ limit: 300 });
|
|
@@ -31,7 +31,7 @@ function readResource(resource, cache) {
|
|
|
31
31
|
case 'event_triggers':
|
|
32
32
|
return require('../events/store').loadEventTriggers().triggers;
|
|
33
33
|
case 'workflows':
|
|
34
|
-
cache.workflows ||= require('../automations/store').
|
|
34
|
+
cache.workflows ||= require('../automations/store').listPublicWorkflows();
|
|
35
35
|
return cache.workflows;
|
|
36
36
|
case 'agents': {
|
|
37
37
|
const { agentWithConfig } = require('../agent-config/store');
|
|
@@ -13,7 +13,7 @@ const { createAgent, resolveAgent } = require('../agents/store');
|
|
|
13
13
|
const { upsertAgentConfig, getAgentConfig } = require('../agent-config/store');
|
|
14
14
|
const { syncInstalledSkills } = require('../skills/store');
|
|
15
15
|
const { defaultToolboxService } = require('../toolbox/service');
|
|
16
|
-
const { createAgentBundle,
|
|
16
|
+
const { createAgentBundle, installBundle } = require('../agent-bundles/bundles');
|
|
17
17
|
|
|
18
18
|
test('agent bundle includes recursive subagents, selected user tools, system tools, and warnings', () => {
|
|
19
19
|
defaultToolboxService.registerTool({
|
|
@@ -145,7 +145,7 @@ test('agent bundle includes every tool action when the loadout references a sing
|
|
|
145
145
|
assert.equal(preview.tools.some((tool) => tool.id === 'browser' && tool.actionCount === browserActions.length), true);
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
-
test('installing an agent bundle creates local copies and rewrites subagent refs', () => {
|
|
148
|
+
test('installing an agent bundle creates local copies and rewrites subagent refs', async () => {
|
|
149
149
|
const source = createAgent({
|
|
150
150
|
id: 'install-source-root',
|
|
151
151
|
name: 'Install Source Root',
|
|
@@ -175,7 +175,7 @@ test('installing an agent bundle creates local copies and rewrites subagent refs
|
|
|
175
175
|
});
|
|
176
176
|
|
|
177
177
|
const { bundle } = createAgentBundle(source.id, { bundleId: 'agent-bundle-install-test' });
|
|
178
|
-
const result =
|
|
178
|
+
const result = await installBundle(bundle);
|
|
179
179
|
|
|
180
180
|
assert.equal(result.ok, true);
|
|
181
181
|
assert.equal(result.installedAgents.length, 2);
|
|
@@ -189,9 +189,9 @@ test('installing an agent bundle creates local copies and rewrites subagent refs
|
|
|
189
189
|
assert.deepEqual(bundle.headIds, ['agent:install-source-root']);
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
-
test('installing an agent bundle rejects missing shared subagents', () => {
|
|
193
|
-
assert.
|
|
194
|
-
() =>
|
|
192
|
+
test('installing an agent bundle rejects missing shared subagents', async () => {
|
|
193
|
+
await assert.rejects(
|
|
194
|
+
() => installBundle({
|
|
195
195
|
kind: 'amalgm.agent.bundle',
|
|
196
196
|
schemaVersion: 1,
|
|
197
197
|
rootAgentId: 'missing-child-root',
|
|
@@ -219,8 +219,8 @@ test('installing an agent bundle rejects missing shared subagents', () => {
|
|
|
219
219
|
);
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
-
test('installing an agent bundle regenerates stale flat graph fields from canonical agent arrays', () => {
|
|
223
|
-
const result =
|
|
222
|
+
test('installing an agent bundle regenerates stale flat graph fields from canonical agent arrays', async () => {
|
|
223
|
+
const result = await installBundle({
|
|
224
224
|
kind: 'amalgm.agent.bundle',
|
|
225
225
|
schemaVersion: 1,
|
|
226
226
|
rootAgentId: 'graph-root',
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
process.env.AMALGM_DIR = require('node:fs').mkdtempSync(
|
|
4
|
+
require('node:path').join(require('node:os').tmpdir(), 'amalgm-bundle-entries-'),
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
const assert = require('node:assert/strict');
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const os = require('node:os');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const test = require('node:test');
|
|
12
|
+
|
|
13
|
+
const { createAutomation, getAutomation, listAutomations } = require('../automations/store');
|
|
14
|
+
const { loadApps, saveApps } = require('../apps/store');
|
|
15
|
+
const { exportAutomationEntry, installAutomationEntry } = require('../agent-bundles/automation-entries');
|
|
16
|
+
const { exportAppEntry, materializeAppEntry } = require('../agent-bundles/app-entries');
|
|
17
|
+
const { packAppFiles, unpackAppFiles } = require('../agent-bundles/app-files');
|
|
18
|
+
const { createBundle, installBundle, validateBundle } = require('../agent-bundles/bundles');
|
|
19
|
+
|
|
20
|
+
function makeTempDir(prefix) {
|
|
21
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeSampleApp(dir) {
|
|
25
|
+
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
26
|
+
fs.mkdirSync(path.join(dir, 'node_modules', 'left-pad'), { recursive: true });
|
|
27
|
+
fs.mkdirSync(path.join(dir, '.git'), { recursive: true });
|
|
28
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'sample-app' }));
|
|
29
|
+
fs.writeFileSync(path.join(dir, 'src', 'index.js'), 'console.log("hi");\n');
|
|
30
|
+
fs.writeFileSync(path.join(dir, '.env'), 'SECRET=super-secret\n');
|
|
31
|
+
fs.writeFileSync(path.join(dir, 'node_modules', 'left-pad', 'index.js'), 'module.exports = () => {};\n');
|
|
32
|
+
fs.writeFileSync(path.join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
33
|
+
fs.writeFileSync(path.join(dir, 'logo.bin'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02]));
|
|
34
|
+
fs.writeFileSync(path.join(dir, 'run.sh'), '#!/bin/sh\necho run\n', { mode: 0o755 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function registerSampleApp(cwd) {
|
|
38
|
+
const data = loadApps();
|
|
39
|
+
const app = {
|
|
40
|
+
id: 'app-sample-test',
|
|
41
|
+
kind: 'app',
|
|
42
|
+
name: 'Sample App',
|
|
43
|
+
description: 'A sample app',
|
|
44
|
+
cwd,
|
|
45
|
+
port: 43210,
|
|
46
|
+
buildCommand: 'npm run build',
|
|
47
|
+
startCommand: 'npm run start -- --port {port}',
|
|
48
|
+
appRef: 'sampleref0001',
|
|
49
|
+
publicUrl: 'https://sampleref0001.apps.amalgm.ai/',
|
|
50
|
+
dnsConnected: true,
|
|
51
|
+
autostart: true,
|
|
52
|
+
keepAlive: true,
|
|
53
|
+
desiredState: 'running',
|
|
54
|
+
status: 'running',
|
|
55
|
+
createdAt: new Date().toISOString(),
|
|
56
|
+
updatedAt: new Date().toISOString(),
|
|
57
|
+
};
|
|
58
|
+
data.apps = data.apps.filter((item) => item.id !== app.id);
|
|
59
|
+
data.apps.push(app);
|
|
60
|
+
saveApps(data);
|
|
61
|
+
return app;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const SCHEDULED_WORKFLOW = `export default workflow({
|
|
65
|
+
trigger: event("scheduled.run"),
|
|
66
|
+
cells: [
|
|
67
|
+
code("start", async () => ({ ok: true }))
|
|
68
|
+
]
|
|
69
|
+
})`;
|
|
70
|
+
|
|
71
|
+
test('automation entry export strips machine state and install recreates an active automation', () => {
|
|
72
|
+
const source = createAutomation({
|
|
73
|
+
name: 'Nightly Digest',
|
|
74
|
+
description: 'Summarize the day',
|
|
75
|
+
enabled: false,
|
|
76
|
+
triggers: [
|
|
77
|
+
{ kind: 'scheduled', name: 'Midnight', schedule: { kind: 'cron', expr: '0 0 * * *', tz: 'UTC' } },
|
|
78
|
+
{ kind: 'event', name: 'Manual hook', source: 'github', event: 'push' },
|
|
79
|
+
],
|
|
80
|
+
workflowText: SCHEDULED_WORKFLOW,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const exported = exportAutomationEntry(source.id);
|
|
84
|
+
const entry = exported.entry;
|
|
85
|
+
|
|
86
|
+
assert.equal(entry.sourceAutomationId, source.id);
|
|
87
|
+
assert.equal(entry.automation.name, 'Nightly Digest');
|
|
88
|
+
assert.equal(entry.triggers.length, 2);
|
|
89
|
+
const scheduled = entry.triggers.find((trigger) => trigger.kind === 'scheduled');
|
|
90
|
+
const eventTrigger = entry.triggers.find((trigger) => trigger.kind === 'event');
|
|
91
|
+
assert.deepEqual(scheduled.schedule, { kind: 'cron', expr: '0 0 * * *', tz: 'UTC' });
|
|
92
|
+
assert.equal(eventTrigger.source, 'github');
|
|
93
|
+
assert.equal('secret' in eventTrigger, false);
|
|
94
|
+
assert.equal('webhookUrl' in eventTrigger, false);
|
|
95
|
+
assert.equal('id' in scheduled, false);
|
|
96
|
+
assert.equal(entry.workflows.length, 1);
|
|
97
|
+
assert.equal(entry.workflows[0].workflowText, SCHEDULED_WORKFLOW);
|
|
98
|
+
assert.equal('projectPath' in entry.workflows[0], false);
|
|
99
|
+
|
|
100
|
+
const installed = installAutomationEntry(entry);
|
|
101
|
+
assert.notEqual(installed.id, source.id);
|
|
102
|
+
assert.equal(installed.enabled, true);
|
|
103
|
+
assert.equal(installed.name, 'Nightly Digest (imported)');
|
|
104
|
+
assert.equal(installed.triggers.length, 2);
|
|
105
|
+
|
|
106
|
+
const installedEvent = installed.triggers.find((trigger) => trigger.kind === 'event');
|
|
107
|
+
const sourceEvent = getAutomation(source.id).triggers.find((trigger) => trigger.kind === 'event');
|
|
108
|
+
assert.equal(Boolean(installedEvent.secret), true);
|
|
109
|
+
assert.notEqual(installedEvent.secret, sourceEvent.secret);
|
|
110
|
+
|
|
111
|
+
const installedScheduled = installed.triggers.find((trigger) => trigger.kind === 'scheduled');
|
|
112
|
+
assert.equal(Boolean(installedScheduled.nextRunAt), true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('automation entry install rejects entries without workflow text', () => {
|
|
116
|
+
assert.throws(
|
|
117
|
+
() => installAutomationEntry({
|
|
118
|
+
automation: { name: 'Broken' },
|
|
119
|
+
triggers: [{ kind: 'scheduled', schedule: { kind: 'cron', expr: '0 0 * * *', tz: 'UTC' } }],
|
|
120
|
+
workflows: [{ name: 'Empty' }],
|
|
121
|
+
}),
|
|
122
|
+
/workflowText/,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('app file packing excludes dependencies, env files, and vcs state', () => {
|
|
127
|
+
const dir = makeTempDir('bundle-app-src-');
|
|
128
|
+
writeSampleApp(dir);
|
|
129
|
+
|
|
130
|
+
const packed = packAppFiles(dir);
|
|
131
|
+
const paths = packed.files.map((file) => file.path);
|
|
132
|
+
|
|
133
|
+
assert.deepEqual(paths, ['logo.bin', 'package.json', 'run.sh', 'src/index.js']);
|
|
134
|
+
assert.equal(packed.files.find((file) => file.path === 'logo.bin').encoding, 'base64');
|
|
135
|
+
assert.equal(packed.files.find((file) => file.path === 'run.sh').executable, true);
|
|
136
|
+
assert.equal(packed.skipped.some((item) => item.path === '.env'), true);
|
|
137
|
+
assert.equal(packed.skipped.some((item) => item.path === 'node_modules/'), true);
|
|
138
|
+
assert.equal(packed.skipped.some((item) => item.path === '.git/'), true);
|
|
139
|
+
|
|
140
|
+
const target = makeTempDir('bundle-app-dst-');
|
|
141
|
+
const result = unpackAppFiles(packed.files, target);
|
|
142
|
+
assert.equal(result.written, 4);
|
|
143
|
+
assert.equal(fs.readFileSync(path.join(target, 'src', 'index.js'), 'utf8'), 'console.log("hi");\n');
|
|
144
|
+
assert.equal(fs.existsSync(path.join(target, '.env')), false);
|
|
145
|
+
assert.equal(Boolean(fs.statSync(path.join(target, 'run.sh')).mode & 0o100), true);
|
|
146
|
+
assert.deepEqual(
|
|
147
|
+
fs.readFileSync(path.join(target, 'logo.bin')),
|
|
148
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02]),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('unpacking rejects paths that escape the install directory', () => {
|
|
153
|
+
const target = makeTempDir('bundle-app-escape-');
|
|
154
|
+
assert.throws(
|
|
155
|
+
() => unpackAppFiles([{ path: '../evil.txt', encoding: 'utf8', content: 'nope' }], target),
|
|
156
|
+
/escapes the install directory/,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('app entry export carries the portable record and install materializes a runnable copy', async () => {
|
|
161
|
+
const dir = makeTempDir('bundle-app-reg-');
|
|
162
|
+
writeSampleApp(dir);
|
|
163
|
+
const app = registerSampleApp(dir);
|
|
164
|
+
|
|
165
|
+
const { entry } = exportAppEntry(app.id);
|
|
166
|
+
assert.equal(entry.app.name, 'Sample App');
|
|
167
|
+
assert.equal(entry.app.startCommand, 'npm run start -- --port {port}');
|
|
168
|
+
assert.equal(entry.app.buildCommand, 'npm run build');
|
|
169
|
+
assert.equal('cwd' in entry.app, false);
|
|
170
|
+
assert.equal('port' in entry.app, false);
|
|
171
|
+
assert.equal('appRef' in entry.app, false);
|
|
172
|
+
assert.equal(entry.files.length, 4);
|
|
173
|
+
|
|
174
|
+
const rootDir = makeTempDir('bundle-app-install-root-');
|
|
175
|
+
const registerInput = materializeAppEntry(entry, { rootDir });
|
|
176
|
+
assert.equal(registerInput.start_command, entry.app.startCommand);
|
|
177
|
+
assert.equal(registerInput.build_command, entry.app.buildCommand);
|
|
178
|
+
assert.equal(registerInput.cwd.startsWith(rootDir), true);
|
|
179
|
+
assert.equal(fs.readFileSync(path.join(registerInput.cwd, 'package.json'), 'utf8'), JSON.stringify({ name: 'sample-app' }));
|
|
180
|
+
|
|
181
|
+
// A second install of the same entry gets its own directory.
|
|
182
|
+
const secondInput = materializeAppEntry(entry, { rootDir });
|
|
183
|
+
assert.notEqual(secondInput.cwd, registerInput.cwd);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('mixed bundle carries automations and apps with heads and installs everything', async () => {
|
|
187
|
+
const dir = makeTempDir('bundle-mixed-app-');
|
|
188
|
+
writeSampleApp(dir);
|
|
189
|
+
const app = registerSampleApp(dir);
|
|
190
|
+
const automation = createAutomation({
|
|
191
|
+
name: 'Mixed Bundle Automation',
|
|
192
|
+
triggers: [{ kind: 'scheduled', schedule: { kind: 'cron', expr: '30 6 * * *', tz: 'UTC' } }],
|
|
193
|
+
workflowText: SCHEDULED_WORKFLOW,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const { bundle, preview } = createBundle({
|
|
197
|
+
automationIds: [automation.id],
|
|
198
|
+
appIds: [app.id],
|
|
199
|
+
}, { bundleId: 'bundle-mixed-test' });
|
|
200
|
+
|
|
201
|
+
assert.equal(bundle.kind, 'amalgm.bundle');
|
|
202
|
+
assert.equal(bundle.summary.automations, 1);
|
|
203
|
+
assert.equal(bundle.summary.apps, 1);
|
|
204
|
+
assert.deepEqual(bundle.headIds, [
|
|
205
|
+
`automation:${automation.id}`,
|
|
206
|
+
`app:${app.id}`,
|
|
207
|
+
]);
|
|
208
|
+
const appElement = bundle.elements.find((element) => element.type === 'app');
|
|
209
|
+
assert.equal(appElement.label, 'Sample App');
|
|
210
|
+
// Graph elements carry a manifest, not file contents.
|
|
211
|
+
assert.equal('content' in appElement.data.files[0], false);
|
|
212
|
+
assert.equal(preview.automations.length, 1);
|
|
213
|
+
assert.equal(preview.apps.length, 1);
|
|
214
|
+
assert.equal(preview.apps[0].files, 4);
|
|
215
|
+
|
|
216
|
+
// Round-trip through JSON like the storage path does.
|
|
217
|
+
const revived = JSON.parse(JSON.stringify(bundle));
|
|
218
|
+
validateBundle(revived);
|
|
219
|
+
|
|
220
|
+
const registered = [];
|
|
221
|
+
const rootDir = makeTempDir('bundle-mixed-install-');
|
|
222
|
+
const before = listAutomations().length;
|
|
223
|
+
const result = await installBundle(revived, {
|
|
224
|
+
appsRootDir: rootDir,
|
|
225
|
+
registerApp: async (input) => {
|
|
226
|
+
registered.push(input);
|
|
227
|
+
return { id: 'app-installed', ...input, status: 'running' };
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
assert.equal(result.ok, true);
|
|
232
|
+
assert.equal(result.installedAutomations.length, 1);
|
|
233
|
+
assert.equal(result.installedApps.length, 1);
|
|
234
|
+
assert.equal(listAutomations().length, before + 1);
|
|
235
|
+
assert.equal(registered.length, 1);
|
|
236
|
+
assert.equal(fs.existsSync(path.join(registered[0].cwd, 'src', 'index.js')), true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('legacy agent-only bundles still validate and empty bundles are rejected', () => {
|
|
240
|
+
assert.throws(
|
|
241
|
+
() => validateBundle({ kind: 'amalgm.bundle', schemaVersion: 1 }),
|
|
242
|
+
/at least one agent, automation, or app/,
|
|
243
|
+
);
|
|
244
|
+
assert.throws(
|
|
245
|
+
() => validateBundle({ kind: 'something.else', schemaVersion: 1 }),
|
|
246
|
+
/Unsupported bundle kind/,
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('rest handlers round-trip a share: preview exports a bundle and install recreates it', async () => {
|
|
251
|
+
const { handlePreview, handleInstall } = require('../agent-bundles/rest');
|
|
252
|
+
const automation = createAutomation({
|
|
253
|
+
name: 'REST Round Trip',
|
|
254
|
+
triggers: [{ kind: 'scheduled', schedule: { kind: 'cron', expr: '15 3 * * *', tz: 'UTC' } }],
|
|
255
|
+
workflowText: SCHEDULED_WORKFLOW,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
function capture() {
|
|
259
|
+
const result = {};
|
|
260
|
+
return {
|
|
261
|
+
result,
|
|
262
|
+
sendJson: (status, payload) => {
|
|
263
|
+
result.status = status;
|
|
264
|
+
result.payload = payload;
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const preview = capture();
|
|
270
|
+
await handlePreview({ automation_ids: [automation.id] }, preview.sendJson);
|
|
271
|
+
assert.equal(preview.result.status, 200);
|
|
272
|
+
assert.equal(preview.result.payload.ok, true);
|
|
273
|
+
assert.equal(preview.result.payload.bundle.kind, 'amalgm.bundle');
|
|
274
|
+
|
|
275
|
+
// Simulate the storage download: what the recipient installs is the JSON round-trip.
|
|
276
|
+
const downloaded = JSON.parse(JSON.stringify(preview.result.payload.bundle));
|
|
277
|
+
|
|
278
|
+
const install = capture();
|
|
279
|
+
await handleInstall({ bundle: downloaded }, install.sendJson);
|
|
280
|
+
assert.equal(install.result.status, 200);
|
|
281
|
+
assert.equal(install.result.payload.ok, true);
|
|
282
|
+
assert.equal(install.result.payload.installedAutomations.length, 1);
|
|
283
|
+
assert.notEqual(install.result.payload.installedAutomations[0].id, automation.id);
|
|
284
|
+
|
|
285
|
+
const empty = capture();
|
|
286
|
+
await handlePreview({}, empty.sendJson);
|
|
287
|
+
assert.equal(empty.result.status, 400);
|
|
288
|
+
});
|
|
@@ -11,7 +11,14 @@ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
|
11
11
|
process.env.AMALGM_EVENTS_PUBLIC_URL = 'https://desktop-release.example/events';
|
|
12
12
|
|
|
13
13
|
const { closeLocalDb } = require('../state/db');
|
|
14
|
-
const {
|
|
14
|
+
const { buildSnapshot } = require('../state/snapshot');
|
|
15
|
+
const {
|
|
16
|
+
listAutomations,
|
|
17
|
+
listPublicAutomations,
|
|
18
|
+
listPublicTriggers,
|
|
19
|
+
listPublicWorkflows,
|
|
20
|
+
listTriggers,
|
|
21
|
+
} = require('../automations/store');
|
|
15
22
|
|
|
16
23
|
test.after(() => {
|
|
17
24
|
closeLocalDb();
|
|
@@ -67,3 +74,32 @@ test('automations store seeds the internal release webhook automation', () => {
|
|
|
67
74
|
assert.equal(prTrigger.webhookUrl, 'https://desktop-release.example/events');
|
|
68
75
|
assert.match(prTrigger.secret, /^[a-f0-9]{32}$/);
|
|
69
76
|
});
|
|
77
|
+
|
|
78
|
+
test('public automation surfaces hide internal release automations', () => {
|
|
79
|
+
assert.equal(
|
|
80
|
+
listPublicAutomations().some((item) => item.id === 'internal-amalgm-desktop-release'),
|
|
81
|
+
false,
|
|
82
|
+
);
|
|
83
|
+
assert.equal(
|
|
84
|
+
listPublicTriggers().some((item) => item.id === 'internal-amalgm-desktop-release-github-push'),
|
|
85
|
+
false,
|
|
86
|
+
);
|
|
87
|
+
assert.equal(
|
|
88
|
+
listPublicWorkflows().some((item) => item.id === 'internal-amalgm-desktop-release-workflow'),
|
|
89
|
+
false,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const snapshot = buildSnapshot('automations,triggers,workflows');
|
|
93
|
+
assert.equal(
|
|
94
|
+
snapshot.resources.automations.some((item) => item.id === 'internal-amalgm-desktop-release'),
|
|
95
|
+
false,
|
|
96
|
+
);
|
|
97
|
+
assert.equal(
|
|
98
|
+
snapshot.resources.triggers.some((item) => item.id === 'internal-amalgm-desktop-release-github-push'),
|
|
99
|
+
false,
|
|
100
|
+
);
|
|
101
|
+
assert.equal(
|
|
102
|
+
snapshot.resources.workflows.some((item) => item.id === 'internal-amalgm-desktop-release-workflow'),
|
|
103
|
+
false,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
@@ -204,6 +204,7 @@ test('desktop release runner uses electron-builder compatible signing identity n
|
|
|
204
204
|
assert.equal(env.AMALGM_DESKTOP_RELEASE_UPLOAD_URL, 'https://amalgm-desktop-releases.fly.dev/');
|
|
205
205
|
assert.equal(env.AMALGM_DESKTOP_RELEASE_FLY_APP, 'amalgm-desktop-releases');
|
|
206
206
|
assert.equal(env.AMALGM_DESKTOP_RELEASE_FLY_ROOT, '/data');
|
|
207
|
+
assert.equal(env.AMALGM_DESKTOP_THIN, '1');
|
|
207
208
|
});
|
|
208
209
|
|
|
209
210
|
test('desktop release runner omits blank certificate file secrets', () => {
|
|
@@ -342,3 +343,24 @@ test('desktop release runner requires all updater artifacts', () => {
|
|
|
342
343
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
343
344
|
}
|
|
344
345
|
});
|
|
346
|
+
|
|
347
|
+
test('desktop release runner accepts thin updater artifacts', () => {
|
|
348
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-desktop-release-thin-artifacts-test-'));
|
|
349
|
+
const version = '0.1.124000001-preview.1';
|
|
350
|
+
const files = [
|
|
351
|
+
`amalgm-preview-thin-${version}-arm64.dmg`,
|
|
352
|
+
`amalgm-preview-thin-${version}-arm64.dmg.blockmap`,
|
|
353
|
+
`amalgm-preview-thin-${version}-arm64.zip`,
|
|
354
|
+
`amalgm-preview-thin-${version}-arm64.zip.blockmap`,
|
|
355
|
+
'preview-mac.yml',
|
|
356
|
+
];
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
for (const file of files) fs.writeFileSync(path.join(tempRoot, file), '');
|
|
360
|
+
const artifacts = desktopReleaseArtifactPaths(tempRoot, 'preview', version)
|
|
361
|
+
.map((filePath) => path.basename(filePath));
|
|
362
|
+
assert.deepEqual(artifacts, files);
|
|
363
|
+
} finally {
|
|
364
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
});
|