@1agh/maude 0.36.1 → 0.38.0
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/README.md +2 -0
- package/apps/studio/acp/bootstrap-brief.ts +93 -0
- package/apps/studio/acp/bridge.ts +114 -1
- package/apps/studio/acp/index.ts +184 -21
- package/apps/studio/acp/plugin-bootstrap.ts +115 -0
- package/apps/studio/acp/transcript.ts +36 -3
- package/apps/studio/activity.ts +45 -0
- package/apps/studio/api.ts +265 -47
- package/apps/studio/bin/_ensure-browser.mjs +305 -0
- package/apps/studio/bin/ensure-browser.sh +26 -0
- package/apps/studio/bin/screenshot.sh +33 -8
- package/apps/studio/bin/smoke.sh +46 -0
- package/apps/studio/canvas-edit.ts +422 -6
- package/apps/studio/canvas-lib.tsx +48 -0
- package/apps/studio/canvas-shell.tsx +684 -12
- package/apps/studio/client/app.jsx +686 -35
- package/apps/studio/client/github.js +6 -0
- package/apps/studio/client/panels/ChatPanel.jsx +593 -31
- package/apps/studio/client/panels/CreateProject.jsx +57 -23
- package/apps/studio/client/panels/GitPanel.jsx +1 -1
- package/apps/studio/client/panels/IdentityBar.jsx +43 -2
- package/apps/studio/client/panels/OnboardingWizard.jsx +24 -21
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +4 -4
- package/apps/studio/client/panels/acp-runtime.js +227 -70
- package/apps/studio/client/panels/chat-context.js +124 -0
- package/apps/studio/client/panels/slash-commands.js +147 -0
- package/apps/studio/client/styles/3-shell-maude.css +15 -0
- package/apps/studio/client/styles/6-acp-chat.css +244 -0
- package/apps/studio/client/tour/collab-tour.js +3 -3
- package/apps/studio/commands/reorder-command.ts +77 -0
- package/apps/studio/config.schema.json +6 -0
- package/apps/studio/dist/client.bundle.js +17 -13
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/github/endpoints.ts +42 -0
- package/apps/studio/hmr-broadcast.ts +27 -19
- package/apps/studio/http.ts +183 -26
- package/apps/studio/inspect.ts +108 -1
- package/apps/studio/paths.ts +32 -0
- package/apps/studio/readiness.ts +79 -30
- package/apps/studio/scaffold-design.ts +95 -2
- package/apps/studio/server.ts +11 -2
- package/apps/studio/test/acp-activity.test.ts +154 -0
- package/apps/studio/test/acp-ai-activity.test.ts +182 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
- package/apps/studio/test/acp-commands.test.ts +108 -0
- package/apps/studio/test/acp-origin-gate.test.ts +64 -1
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
- package/apps/studio/test/acp-session-plugins.test.ts +132 -0
- package/apps/studio/test/acp-transcript.test.ts +53 -0
- package/apps/studio/test/active-state.test.ts +41 -0
- package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +10 -0
- package/apps/studio/test/canvas-reorder.test.ts +211 -0
- package/apps/studio/test/chat-context.test.ts +129 -0
- package/apps/studio/test/csrf-write-guard.test.ts +24 -0
- package/apps/studio/test/edit-suppress.test.ts +170 -0
- package/apps/studio/test/ensure-browser.test.ts +92 -0
- package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
- package/apps/studio/test/hmr-broadcast.test.ts +44 -0
- package/apps/studio/test/inspect-selections.test.ts +219 -0
- package/apps/studio/test/paths.test.ts +57 -0
- package/apps/studio/test/readiness.test.ts +83 -13
- package/apps/studio/test/reorder-api.test.ts +210 -0
- package/apps/studio/test/slash-commands.test.ts +117 -0
- package/apps/studio/test/sync-agent.test.ts +9 -2
- package/apps/studio/undo-stack.ts +6 -0
- package/apps/studio/whats-new.json +63 -0
- package/apps/studio/ws.ts +37 -0
- package/cli/commands/design.mjs +1 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +3 -3
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// POST /_api/reorder round-trip (DDR-138, phase-12.1). Boots a real server,
|
|
2
|
+
// serves a canvas (which injects data-cd-id + writes _locator.json), then drives
|
|
3
|
+
// a node-move reorder through the HTTP layer and asserts: the source reorders on
|
|
4
|
+
// disk, the response carries the recomputed movedId, a pre-reorder history
|
|
5
|
+
// snapshot is written (so /design:rollback can undo it), and a cross-origin POST
|
|
6
|
+
// is CSRF-rejected. Route unreachability from the canvas origin is proven
|
|
7
|
+
// separately in canvas-origin-gate.test.ts.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
14
|
+
|
|
15
|
+
const CANVAS_SRC = `export default function List() {
|
|
16
|
+
return (
|
|
17
|
+
<section>
|
|
18
|
+
<div>A</div>
|
|
19
|
+
<div>B</div>
|
|
20
|
+
<div>C</div>
|
|
21
|
+
</section>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
/** After a GET of the canvas, read _locator.json and return the div ids ordered
|
|
27
|
+
* by source line (== authored order A, B, C). */
|
|
28
|
+
async function divIdsByLine(main: string, designRoot: string): Promise<string[]> {
|
|
29
|
+
// Serving the canvas triggers pass-1 id injection + _locator.json write.
|
|
30
|
+
const r = await fetch(`${main}/.design/ui/List.tsx`, { signal: AbortSignal.timeout(2000) });
|
|
31
|
+
expect(r.status).toBe(200);
|
|
32
|
+
const locator = JSON.parse(readFileSync(join(designRoot, '_locator.json'), 'utf8'));
|
|
33
|
+
const map = locator['ui/List'] as Record<string, { line: number; jsxPath: string[] }>;
|
|
34
|
+
return Object.entries(map)
|
|
35
|
+
.filter(([, e]) => e.jsxPath[e.jsxPath.length - 1] === 'div')
|
|
36
|
+
.sort((a, b) => a[1].line - b[1].line)
|
|
37
|
+
.map(([id]) => id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The visible letter order (A/B/C) in the on-disk source — reflects sibling order. */
|
|
41
|
+
function letterOrder(src: string): string[] {
|
|
42
|
+
return [...src.matchAll(/>([ABC])</g)].map((m) => m[1] as string);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('POST /_api/reorder — HTTP round-trip', () => {
|
|
46
|
+
test('moves a sibling on disk, returns movedId, snapshots pre-reorder, CSRF-guards', async () => {
|
|
47
|
+
const { root, designRoot } = makeSandbox();
|
|
48
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
49
|
+
const canvasPath = join(designRoot, 'ui', 'List.tsx');
|
|
50
|
+
writeFileSync(canvasPath, CANVAS_SRC);
|
|
51
|
+
|
|
52
|
+
const port = nextPort();
|
|
53
|
+
const main = `http://localhost:${port}`;
|
|
54
|
+
const proc = await bootServer(root, port);
|
|
55
|
+
try {
|
|
56
|
+
const [aId, , cId] = await divIdsByLine(main, designRoot);
|
|
57
|
+
|
|
58
|
+
// Move A after C → order becomes B, C, A.
|
|
59
|
+
const res = await fetch(`${main}/_api/reorder`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
// No Origin header → same-origin/programmatic; sameOriginWrite allows it.
|
|
62
|
+
body: JSON.stringify({ canvas: 'ui/List', id: aId, refId: cId, position: 'after' }),
|
|
63
|
+
signal: AbortSignal.timeout(2000),
|
|
64
|
+
});
|
|
65
|
+
expect(res.status).toBe(200);
|
|
66
|
+
const json = (await res.json()) as {
|
|
67
|
+
ok: boolean;
|
|
68
|
+
movedId: string | null;
|
|
69
|
+
semanticId: string | null;
|
|
70
|
+
};
|
|
71
|
+
expect(json.ok).toBe(true);
|
|
72
|
+
expect(json.movedId).toMatch(/^[0-9a-f]{8}$/);
|
|
73
|
+
|
|
74
|
+
// Source reordered on disk.
|
|
75
|
+
expect(letterOrder(readFileSync(canvasPath, 'utf8'))).toEqual(['B', 'C', 'A']);
|
|
76
|
+
|
|
77
|
+
// A pre-reorder snapshot landed under _history/ui-list/ (rollback path).
|
|
78
|
+
const histDir = join(designRoot, '_history', 'ui-list');
|
|
79
|
+
expect(existsSync(histDir)).toBe(true);
|
|
80
|
+
const metas = readdirSync(histDir)
|
|
81
|
+
.filter((f) => f.endsWith('.json'))
|
|
82
|
+
.map((f) => JSON.parse(readFileSync(join(histDir, f), 'utf8')) as { reason: string });
|
|
83
|
+
expect(metas.some((m) => m.reason === 'pre-reorder')).toBe(true);
|
|
84
|
+
|
|
85
|
+
// Cross-origin POST is CSRF-rejected (403), leaving the file untouched.
|
|
86
|
+
const forged = await fetch(`${main}/_api/reorder`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { origin: 'http://evil.example' },
|
|
89
|
+
body: JSON.stringify({ canvas: 'ui/List', id: cId, refId: aId, position: 'before' }),
|
|
90
|
+
signal: AbortSignal.timeout(2000),
|
|
91
|
+
});
|
|
92
|
+
expect(forged.status).toBe(403);
|
|
93
|
+
expect(letterOrder(readFileSync(canvasPath, 'utf8'))).toEqual(['B', 'C', 'A']);
|
|
94
|
+
|
|
95
|
+
// A self-move is a 422 refusal (guardrail), not a 500.
|
|
96
|
+
const bad = await fetch(`${main}/_api/reorder`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
body: JSON.stringify({ canvas: 'ui/List', id: aId, refId: aId, position: 'after' }),
|
|
99
|
+
signal: AbortSignal.timeout(2000),
|
|
100
|
+
});
|
|
101
|
+
expect(bad.status).toBe(422);
|
|
102
|
+
} finally {
|
|
103
|
+
await killProc(proc);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('POST /_api/reorder-revert — Cmd+Z round-trip', () => {
|
|
109
|
+
test('undo restores the pre-reorder source, redo re-applies, stale content 409s', async () => {
|
|
110
|
+
const { root, designRoot } = makeSandbox();
|
|
111
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
112
|
+
const canvasPath = join(designRoot, 'ui', 'List.tsx');
|
|
113
|
+
writeFileSync(canvasPath, CANVAS_SRC);
|
|
114
|
+
|
|
115
|
+
const port = nextPort();
|
|
116
|
+
const main = `http://localhost:${port}`;
|
|
117
|
+
const proc = await bootServer(root, port);
|
|
118
|
+
try {
|
|
119
|
+
const [aId, , cId] = await divIdsByLine(main, designRoot);
|
|
120
|
+
|
|
121
|
+
// Reorder A after C → B, C, A; the response carries the revert-log seq.
|
|
122
|
+
const res = await fetch(`${main}/_api/reorder`, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
body: JSON.stringify({ canvas: 'ui/List', id: aId, refId: cId, position: 'after' }),
|
|
125
|
+
signal: AbortSignal.timeout(2000),
|
|
126
|
+
});
|
|
127
|
+
const json = (await res.json()) as { ok: boolean; seq: number };
|
|
128
|
+
expect(json.ok).toBe(true);
|
|
129
|
+
expect(typeof json.seq).toBe('number');
|
|
130
|
+
expect(letterOrder(readFileSync(canvasPath, 'utf8'))).toEqual(['B', 'C', 'A']);
|
|
131
|
+
|
|
132
|
+
// Undo → back to A, B, C.
|
|
133
|
+
const undo = await fetch(`${main}/_api/reorder-revert`, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
body: JSON.stringify({ canvas: 'ui/List', seq: json.seq, dir: 'undo' }),
|
|
136
|
+
signal: AbortSignal.timeout(2000),
|
|
137
|
+
});
|
|
138
|
+
expect(undo.status).toBe(200);
|
|
139
|
+
expect(letterOrder(readFileSync(canvasPath, 'utf8'))).toEqual(['A', 'B', 'C']);
|
|
140
|
+
|
|
141
|
+
// Redo → B, C, A again.
|
|
142
|
+
const redo = await fetch(`${main}/_api/reorder-revert`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
body: JSON.stringify({ canvas: 'ui/List', seq: json.seq, dir: 'redo' }),
|
|
145
|
+
signal: AbortSignal.timeout(2000),
|
|
146
|
+
});
|
|
147
|
+
expect(redo.status).toBe(200);
|
|
148
|
+
expect(letterOrder(readFileSync(canvasPath, 'utf8'))).toEqual(['B', 'C', 'A']);
|
|
149
|
+
|
|
150
|
+
// External edit → undo refuses with 409 and leaves the file alone.
|
|
151
|
+
const edited = readFileSync(canvasPath, 'utf8').replace('>B<', '>B!<');
|
|
152
|
+
writeFileSync(canvasPath, edited);
|
|
153
|
+
const stale = await fetch(`${main}/_api/reorder-revert`, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
body: JSON.stringify({ canvas: 'ui/List', seq: json.seq, dir: 'undo' }),
|
|
156
|
+
signal: AbortSignal.timeout(2000),
|
|
157
|
+
});
|
|
158
|
+
expect(stale.status).toBe(409);
|
|
159
|
+
expect(readFileSync(canvasPath, 'utf8')).toBe(edited);
|
|
160
|
+
|
|
161
|
+
// Unknown seq → 404; forged cross-origin POST → 403.
|
|
162
|
+
const missing = await fetch(`${main}/_api/reorder-revert`, {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
body: JSON.stringify({ canvas: 'ui/List', seq: 99999, dir: 'undo' }),
|
|
165
|
+
signal: AbortSignal.timeout(2000),
|
|
166
|
+
});
|
|
167
|
+
expect(missing.status).toBe(404);
|
|
168
|
+
const forged = await fetch(`${main}/_api/reorder-revert`, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: { origin: 'http://evil.example' },
|
|
171
|
+
body: JSON.stringify({ canvas: 'ui/List', seq: json.seq, dir: 'undo' }),
|
|
172
|
+
signal: AbortSignal.timeout(2000),
|
|
173
|
+
});
|
|
174
|
+
expect(forged.status).toBe(403);
|
|
175
|
+
} finally {
|
|
176
|
+
await killProc(proc);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Adversarial F3 (DDR-139): a REJECTED reorder must NOT deposit a _history
|
|
182
|
+
// snapshot — the pre-move snapshot + revert-log are written only on a confirmed
|
|
183
|
+
// write, so a stream of failing requests can't disk-fill _history.
|
|
184
|
+
describe('POST /_api/reorder — a rejected move writes no snapshot', () => {
|
|
185
|
+
test('an unknown-refId reorder 422s and leaves _history empty', async () => {
|
|
186
|
+
const { root, designRoot } = makeSandbox();
|
|
187
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
188
|
+
writeFileSync(join(designRoot, 'ui', 'List.tsx'), CANVAS_SRC);
|
|
189
|
+
const port = nextPort();
|
|
190
|
+
const main = `http://localhost:${port}`;
|
|
191
|
+
const proc = await bootServer(root, port);
|
|
192
|
+
try {
|
|
193
|
+
const [aId] = await divIdsByLine(main, designRoot);
|
|
194
|
+
const bad = await fetch(`${main}/_api/reorder`, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
body: JSON.stringify({ canvas: 'ui/List', id: aId, refId: 'deadbeef', position: 'after' }),
|
|
197
|
+
signal: AbortSignal.timeout(2000),
|
|
198
|
+
});
|
|
199
|
+
expect(bad.status).toBe(422); // refId not found → moveElement throws
|
|
200
|
+
// No pre-reorder snapshot was written (the move never landed).
|
|
201
|
+
const histDir = join(designRoot, '_history', 'ui-list');
|
|
202
|
+
const snaps = existsSync(histDir)
|
|
203
|
+
? readdirSync(histDir).filter((f) => f.endsWith('.json'))
|
|
204
|
+
: [];
|
|
205
|
+
expect(snaps.length).toBe(0);
|
|
206
|
+
} finally {
|
|
207
|
+
await killProc(proc);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Unit tests for the pure command model behind the ACP composer autocomplete +
|
|
2
|
+
// inline pill (client/panels/slash-commands.js). No DOM — importable directly.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
buildCommandModel,
|
|
8
|
+
filterCommands,
|
|
9
|
+
groupOf,
|
|
10
|
+
matchLeadingCommand,
|
|
11
|
+
normalizeName,
|
|
12
|
+
STATIC_COMMANDS,
|
|
13
|
+
} from '../client/panels/slash-commands.js';
|
|
14
|
+
|
|
15
|
+
describe('normalizeName', () => {
|
|
16
|
+
test('strips a leading slash, lowercases, trims', () => {
|
|
17
|
+
expect(normalizeName('/design:Edit')).toBe('design:edit');
|
|
18
|
+
expect(normalizeName(' FLOW:Plan ')).toBe('flow:plan');
|
|
19
|
+
expect(normalizeName('design:edit')).toBe('design:edit');
|
|
20
|
+
});
|
|
21
|
+
test('is empty-safe', () => {
|
|
22
|
+
expect(normalizeName('')).toBe('');
|
|
23
|
+
expect(normalizeName(null as unknown as string)).toBe('');
|
|
24
|
+
expect(normalizeName(undefined as unknown as string)).toBe('');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('groupOf', () => {
|
|
29
|
+
test('extracts the plugin prefix', () => {
|
|
30
|
+
expect(groupOf('design:edit')).toBe('design');
|
|
31
|
+
expect(groupOf('flow:bug-fix')).toBe('flow');
|
|
32
|
+
expect(groupOf('bare')).toBe('other');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('matchLeadingCommand', () => {
|
|
37
|
+
test('typing mode: leading slash token with no space', () => {
|
|
38
|
+
expect(matchLeadingCommand('/')).toEqual({ token: '', full: null, typing: true });
|
|
39
|
+
expect(matchLeadingCommand('/desi')).toEqual({ token: 'desi', full: null, typing: true });
|
|
40
|
+
expect(matchLeadingCommand('/design:edit')).toEqual({
|
|
41
|
+
token: 'design:edit',
|
|
42
|
+
full: null,
|
|
43
|
+
typing: true,
|
|
44
|
+
});
|
|
45
|
+
// Leading whitespace is tolerated (trimmed).
|
|
46
|
+
expect(matchLeadingCommand(' /design')).toEqual({
|
|
47
|
+
token: 'design',
|
|
48
|
+
full: null,
|
|
49
|
+
typing: true,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
test('highlight mode: command followed by a space', () => {
|
|
53
|
+
expect(matchLeadingCommand('/design:edit make it bolder')).toEqual({
|
|
54
|
+
token: 'design:edit',
|
|
55
|
+
full: '/design:edit',
|
|
56
|
+
typing: false,
|
|
57
|
+
});
|
|
58
|
+
expect(matchLeadingCommand('/flow:quick ')).toEqual({
|
|
59
|
+
token: 'flow:quick',
|
|
60
|
+
full: '/flow:quick',
|
|
61
|
+
typing: false,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
test('non-command text returns null', () => {
|
|
65
|
+
expect(matchLeadingCommand('make the button bigger')).toBeNull();
|
|
66
|
+
expect(matchLeadingCommand('email me at a/b')).toBeNull(); // mid-string slash is not a command
|
|
67
|
+
expect(matchLeadingCommand('')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('buildCommandModel', () => {
|
|
72
|
+
test('cold (no live list) → static keys are the optimistic exists-set', () => {
|
|
73
|
+
const { all, existsSet } = buildCommandModel(STATIC_COMMANDS, []);
|
|
74
|
+
expect(all.length).toBe(STATIC_COMMANDS.length);
|
|
75
|
+
expect(existsSet.has('design:edit')).toBe(true);
|
|
76
|
+
expect(existsSet.has('design:new')).toBe(true);
|
|
77
|
+
// every static command is present + flagged not-live
|
|
78
|
+
expect(all.every((c) => c.live === false)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('warm (live list) → live names are the strict authority, and demote unknowns', () => {
|
|
82
|
+
const live = [
|
|
83
|
+
{ name: 'design:edit', description: 'live desc' },
|
|
84
|
+
{ name: '/design:new', description: '' }, // slash + separator variance tolerated
|
|
85
|
+
{ name: 'user:custom', description: 'a user command' },
|
|
86
|
+
];
|
|
87
|
+
const { all, existsSet } = buildCommandModel(STATIC_COMMANDS, live);
|
|
88
|
+
// exists-set is strictly the live set
|
|
89
|
+
expect(existsSet.has('design:edit')).toBe(true);
|
|
90
|
+
expect(existsSet.has('design:new')).toBe(true);
|
|
91
|
+
expect(existsSet.has('user:custom')).toBe(true);
|
|
92
|
+
// a shipped static command NOT in the live set is no longer "exists"
|
|
93
|
+
expect(existsSet.has('design:critic')).toBe(false);
|
|
94
|
+
// live-only command is added to the union
|
|
95
|
+
expect(all.find((c) => c.name === 'user:custom')?.live).toBe(true);
|
|
96
|
+
// static command present in live is marked live
|
|
97
|
+
expect(all.find((c) => c.name === 'design:edit')?.live).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('filterCommands', () => {
|
|
102
|
+
test('empty token returns the capped list', () => {
|
|
103
|
+
const { all } = buildCommandModel(STATIC_COMMANDS, []);
|
|
104
|
+
expect(filterCommands(all, '', 5).length).toBe(5);
|
|
105
|
+
});
|
|
106
|
+
test('prefix matches rank before substring matches', () => {
|
|
107
|
+
const { all } = buildCommandModel(STATIC_COMMANDS, []);
|
|
108
|
+
const res = filterCommands(all, 'design:', 20);
|
|
109
|
+
expect(res.length).toBeGreaterThan(0);
|
|
110
|
+
expect(res.every((c) => c.name.startsWith('design:'))).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
test('matches on description substring too', () => {
|
|
113
|
+
const { all } = buildCommandModel(STATIC_COMMANDS, []);
|
|
114
|
+
const res = filterCommands(all, 'critic', 20);
|
|
115
|
+
expect(res.some((c) => c.name === 'design:critic')).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -190,14 +190,21 @@ describe('CanvasSyncAgent — cold start reconciliation', () => {
|
|
|
190
190
|
agent = makeAgent({ adopt: true });
|
|
191
191
|
await agent.reconcile();
|
|
192
192
|
|
|
193
|
-
// Hub changes the value
|
|
193
|
+
// Hub changes the value — stamp it explicitly newer than the disk so the
|
|
194
|
+
// newest-wins reconcile is deterministic. An un-pinned `applyHtmlToDoc` stamp
|
|
195
|
+
// is `Date.now()` (ms-truncated) while the disk mtime is sub-ms `statSync`:
|
|
196
|
+
// in isolation they land in the same ms → tie → hub; under parallel-suite
|
|
197
|
+
// load they straddle a ms boundary → disk looks newer → local, flaking this
|
|
198
|
+
// assertion. Sibling tests pin timestamps for exactly this reason.
|
|
194
199
|
applyHtmlToDoc(docA, '<button>hub-v2</button>');
|
|
200
|
+
docA.getMap('syncMeta').set('bodyEditAt', Date.now() + 60_000);
|
|
195
201
|
|
|
196
202
|
// Disk reverts to a stale local-only state.
|
|
197
203
|
writeFileSync(paths().html, '<button>local-v3</button>');
|
|
198
204
|
await agent.reconcile();
|
|
199
205
|
|
|
200
|
-
// Hub state won this time
|
|
206
|
+
// Hub state won this time — adopt was consumed, so the second reconcile runs
|
|
207
|
+
// normal newest-wins, and the (explicitly newer) hub body wins.
|
|
201
208
|
expect(htmlFromDoc(docB)).toBe('<button>hub-v2</button>');
|
|
202
209
|
});
|
|
203
210
|
|
|
@@ -81,6 +81,12 @@ export interface CommandSinks {
|
|
|
81
81
|
* snapshot). See `commands/edit-source-command.ts`.
|
|
82
82
|
*/
|
|
83
83
|
editSourceApplyFn?: unknown;
|
|
84
|
+
/**
|
|
85
|
+
* Wired by `CanvasShell`. Reverts/re-applies a Phase 12.1 element reorder by
|
|
86
|
+
* posting `dgn:'reorder-revert'` to the parent shell, which owns the
|
|
87
|
+
* main-origin-only `/_api/reorder-revert` write. See `commands/reorder-command.ts`.
|
|
88
|
+
*/
|
|
89
|
+
reorderRevertFn?: unknown;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
export type CommandBuilder<P = unknown> = (
|
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "acp-context-hardening",
|
|
6
|
+
"version": "0.38.0",
|
|
7
|
+
"date": "2026-07-03",
|
|
8
|
+
"kind": "feature",
|
|
9
|
+
"title": "The Assistant keeps your canvas in view",
|
|
10
|
+
"summary": "Selection now sticks per canvas: pick an element, switch to another canvas and back, and it's still selected — no more losing your place. Every message you send to the Assistant carries the canvas and element you had chosen, frozen at send time and shown as a chip above the box, so switching canvases while Claude works never leaves it guessing what you meant. And a fresh chat already knows it's running inside the Maude studio, so you can just say \"make this bigger\" and go.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "acp-composer-paste-and-send",
|
|
15
|
+
"version": "0.38.0",
|
|
16
|
+
"date": "2026-07-03",
|
|
17
|
+
"kind": "feature",
|
|
18
|
+
"title": "Paste, copy, and send in the Assistant",
|
|
19
|
+
"summary": "Copy and paste now work in the desktop app, and Enter sends your message (Shift+Enter for a new line). Paste a file path, a link, or an image straight from your clipboard and it becomes a tidy chip — with a line underneath showing exactly what each chip will send, so nothing hidden reaches Claude. A pasted screenshot is saved with the chat and Claude reads it on send.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "slash-command-autocomplete",
|
|
24
|
+
"version": "0.38.0",
|
|
25
|
+
"date": "2026-07-03",
|
|
26
|
+
"kind": "feature",
|
|
27
|
+
"title": "Slash-command autocomplete in the Assistant",
|
|
28
|
+
"summary": "In the Assistant chat, type \"/\" to see the design and flow commands your Claude actually has — filter as you type, pick with the keyboard or a click (it prefills, never sends). A command that exists lights up as a pill right inside the box, so you don't have to remember the exact names.",
|
|
29
|
+
"surface": "design-ui"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "drag-to-reorder",
|
|
33
|
+
"version": "0.38.0",
|
|
34
|
+
"date": "2026-07-03",
|
|
35
|
+
"kind": "feature",
|
|
36
|
+
"title": "Drag to reorder elements",
|
|
37
|
+
"summary": "Rearrange a canvas by hand. Drag the element itself on the canvas — drop on the top/left half to go above, the bottom/right half to go below, or the middle of a box to drop inside it — and the layout reflows LIVE while you hold, Figma-style, with the neighbours gliding into place; Esc puts it back. Or drag in the Layers panel, which now mirrors the canvas both ways as you move. Even instances of the same reusable component (a board's columns, repeated cards) reorder now. Every reorder rewrites your .tsx source and undoes with ⌘Z / redoes with ⌘⇧Z. Keyboard: with the panel focused, ↑/↓ walk the selection, Alt+↑/↓ move within the parent, Alt+Shift+↑/↓ move across.",
|
|
38
|
+
"surface": "design-ui"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "new-project-menu-and-local",
|
|
42
|
+
"version": "0.37.0",
|
|
43
|
+
"date": "2026-07-01",
|
|
44
|
+
"kind": "feature",
|
|
45
|
+
"title": "Start a project from the menu — or fully local",
|
|
46
|
+
"summary": "New Project… is now in the File menu (⌘N), not just onboarding. And you can create a project on This computer only — a plain local git repo with no GitHub and no remote, that you can publish later. It works even when you're signed out.",
|
|
47
|
+
"surface": "design-ui"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "desktop-onboarding-fixes",
|
|
51
|
+
"version": "0.36.2",
|
|
52
|
+
"date": "2026-06-30",
|
|
53
|
+
"kind": "fix",
|
|
54
|
+
"title": "A smoother first run",
|
|
55
|
+
"summary": "Onboarding got a polish. If you're already signed in, the GitHub step now says Continue instead of asking you to sign in again, and your signed-in status shows reliably. Cancelling the sign-in code screen frees the button right away, “Merge this branch → main” shows progress while it runs, and a brand-new project opens to a starter canvas instead of an empty workspace.",
|
|
56
|
+
"surface": "design-ui"
|
|
57
|
+
},
|
|
4
58
|
{
|
|
5
59
|
"id": "git-native-switcher",
|
|
6
60
|
"version": "0.36.0",
|
|
@@ -368,6 +422,15 @@
|
|
|
368
422
|
"title": "Self-hostable hub + live file sync",
|
|
369
423
|
"summary": "Run your own Maude hub and co-edit canvases with bidirectional file sync, offline-aware status, and conflict handling.",
|
|
370
424
|
"surface": "design-ui"
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
"id": "zero-install-desktop-design",
|
|
428
|
+
"version": "0.38.0",
|
|
429
|
+
"date": "2026-07-03",
|
|
430
|
+
"kind": "feature",
|
|
431
|
+
"title": "Design just works in the desktop chat",
|
|
432
|
+
"summary": "With only Claude Code installed, /design:* commands auto-load in the chat (nothing to install), and design critics can now see your artboards — a screenshot engine is bundled and provisions its browser on first use.",
|
|
433
|
+
"surface": "design-ui"
|
|
371
434
|
}
|
|
372
435
|
]
|
|
373
436
|
}
|
package/apps/studio/ws.ts
CHANGED
|
@@ -81,6 +81,43 @@ export function isLoopbackHost(host: string | null): boolean {
|
|
|
81
81
|
return h === '127.0.0.1' || h === '::1' || h === 'localhost';
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** Port component of a `host[:port]` / `[::1]:port` string ('' if default-port). */
|
|
85
|
+
function hostPort(host: string): string {
|
|
86
|
+
const h = host.trim();
|
|
87
|
+
if (h.startsWith('[')) {
|
|
88
|
+
const close = h.indexOf(']');
|
|
89
|
+
return close !== -1 ? h.slice(close + 1).replace(/^:/, '') : '';
|
|
90
|
+
}
|
|
91
|
+
const colon = h.lastIndexOf(':');
|
|
92
|
+
return colon !== -1 ? h.slice(colon + 1) : '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* CSWSH gate for the privileged ACP WebSocket. Loopback-Host is NOT enough: a
|
|
97
|
+
* WS handshake bypasses the same-origin policy, so a cross-origin drive-by page
|
|
98
|
+
* (`http://evil.com`, or the user's own `http://localhost:3000` dev server) can
|
|
99
|
+
* open `ws://localhost:<port>/_ws/acp` — and the bridge spawns the user's
|
|
100
|
+
* `claude` + drives file edits. A browser ALWAYS sends `Origin` on a cross-doc
|
|
101
|
+
* WS connect; a non-browser client (CLI / tests) omits it. Allow when Origin is
|
|
102
|
+
* absent, or when it is a loopback host on the SAME port as the request — which
|
|
103
|
+
* is exactly what the native panel and a same-origin `maude design serve` tab
|
|
104
|
+
* send (both navigate to `http://localhost:<serverport>`). Reject everything
|
|
105
|
+
* else. Mirrors the `sameOriginWrite` (http.ts) discipline for the WS upgrade.
|
|
106
|
+
*/
|
|
107
|
+
export function isSameOriginWs(req: Request): boolean {
|
|
108
|
+
const origin = req.headers.get('origin');
|
|
109
|
+
if (!origin) return true; // non-browser (CLI / tests) — browsers send Origin on WS
|
|
110
|
+
let originHost: string;
|
|
111
|
+
try {
|
|
112
|
+
originHost = new URL(origin).host; // host[:port]; `Origin: null` → throws → reject
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
const reqHost = req.headers.get('host');
|
|
117
|
+
if (!isLoopbackHost(originHost) || !isLoopbackHost(reqHost)) return false;
|
|
118
|
+
return hostPort(originHost) === hostPort(reqHost ?? '');
|
|
119
|
+
}
|
|
120
|
+
|
|
84
121
|
export interface Ws {
|
|
85
122
|
handler: WebSocketHandler<WsData>;
|
|
86
123
|
broadcast(payload: unknown): void;
|
package/cli/commands/design.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"dev": "bun run apps/studio/server.ts --port 4555",
|
|
22
22
|
"dev:site": "pnpm --filter @maude/site dev --port 4398",
|
|
23
23
|
"dev:desktop": "PATH=\"$HOME/.cargo/bin:$PATH\" pnpm --filter @maude/desktop tauri dev",
|
|
24
|
+
"dev:desktop:onboarding": "PATH=\"$HOME/.cargo/bin:$PATH\" MAUDE_FORCE_ONBOARDING=1 pnpm --filter @maude/desktop tauri dev",
|
|
24
25
|
"build:desktop": "PATH=\"$HOME/.cargo/bin:$PATH\" pnpm --filter @maude/desktop tauri build",
|
|
25
26
|
"test:e2e:desktop:build": "PATH=\"$HOME/.cargo/bin:$PATH\" pnpm --filter @maude/desktop tauri build --debug --config src-tauri/tauri.e2e.conf.json",
|
|
26
27
|
"test:e2e:desktop": "pnpm --filter @maude/desktop-e2e e2e",
|
|
@@ -45,16 +46,17 @@
|
|
|
45
46
|
"postinstall": "node cli/install.cjs",
|
|
46
47
|
"prepublishOnly": "bash scripts/check-version-parity.sh && bash apps/studio/bin/check-runtime-bundles.sh",
|
|
47
48
|
"test:e2e:desktop:lifecycle": "pnpm --filter @maude/desktop-e2e e2e:lifecycle",
|
|
48
|
-
"test:e2e:desktop:switchrepos": "pnpm --filter @maude/desktop-e2e e2e:switchrepos"
|
|
49
|
+
"test:e2e:desktop:switchrepos": "pnpm --filter @maude/desktop-e2e e2e:switchrepos",
|
|
50
|
+
"test:e2e:desktop:onboarding": "pnpm --filter @maude/desktop-e2e e2e:onboarding"
|
|
49
51
|
},
|
|
50
52
|
"optionalDependencies": {
|
|
51
|
-
"@1agh/maude-darwin-arm64": "0.
|
|
52
|
-
"@1agh/maude-darwin-x64": "0.
|
|
53
|
-
"@1agh/maude-linux-arm64": "0.
|
|
54
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
55
|
-
"@1agh/maude-linux-x64": "0.
|
|
56
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
57
|
-
"@1agh/maude-win32-x64": "0.
|
|
53
|
+
"@1agh/maude-darwin-arm64": "0.38.0",
|
|
54
|
+
"@1agh/maude-darwin-x64": "0.38.0",
|
|
55
|
+
"@1agh/maude-linux-arm64": "0.38.0",
|
|
56
|
+
"@1agh/maude-linux-arm64-musl": "0.38.0",
|
|
57
|
+
"@1agh/maude-linux-x64": "0.38.0",
|
|
58
|
+
"@1agh/maude-linux-x64-musl": "0.38.0",
|
|
59
|
+
"@1agh/maude-win32-x64": "0.38.0"
|
|
58
60
|
},
|
|
59
61
|
"files": [
|
|
60
62
|
"cli",
|
|
@@ -65,10 +65,10 @@
|
|
|
65
65
|
"expectExit": 0
|
|
66
66
|
},
|
|
67
67
|
"install": {
|
|
68
|
-
"preferred": "npm i -g
|
|
68
|
+
"preferred": "npm i -g agent-browser"
|
|
69
69
|
},
|
|
70
70
|
"autoInstall": true,
|
|
71
|
-
"fallbackBehavior": "Screenshot helpers fall back to `npx playwright` (slower, less reliable). /design:smoke and several critics need agent-browser for full coverage.",
|
|
71
|
+
"fallbackBehavior": "Screenshot helpers fall back to `npx playwright` (slower, less reliable). /design:smoke and several critics need agent-browser for full coverage. On the Maude DESKTOP app it is BUNDLED (externalBin, DDR-144) and its Chromium engine is provisioned as chrome-headless-shell by `maude design ensure-browser` — screenshots work zero-install there; this soft/manual note applies to the web `maude design serve` + CLI paths.",
|
|
72
72
|
"usedBy": [
|
|
73
73
|
"commands/screenshot.md",
|
|
74
74
|
"commands/new.md",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"agents/graphic-design-critic.md",
|
|
81
81
|
"agents/a11y-critic.md"
|
|
82
82
|
],
|
|
83
|
-
"docsUrl": "https://github.com/
|
|
83
|
+
"docsUrl": "https://github.com/vercel-labs/agent-browser"
|
|
84
84
|
},
|
|
85
85
|
{
|
|
86
86
|
"id": "playwright",
|