@1agh/maude 0.56.0 → 0.58.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 +5 -1
- package/apps/studio/acp/bridge.ts +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/api.ts +69 -4
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +187 -39
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +72 -13
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/panels/chat-markdown.jsx +76 -5
- package/apps/studio/client/panels/file-preview.jsx +115 -0
- package/apps/studio/client/styles/3-shell-maude.css +56 -2
- package/apps/studio/client/styles/6-acp-chat.css +79 -0
- package/apps/studio/dist/client.bundle.js +1436 -1436
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +59 -2
- package/apps/studio/input-router.tsx +39 -1
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +50 -1
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +11 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/config-version.test.ts +88 -0
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/input-router.test.ts +56 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/test/whats-new.test.ts +13 -1
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/whats-new.ts +34 -10
- package/package.json +8 -8
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// The one rule both sync surfaces read — see sync/presentation.ts.
|
|
2
|
+
//
|
|
3
|
+
// Pins the two SURFACE defects that the monitor-level falsifier
|
|
4
|
+
// (`sync-connect-honesty.test.ts`) deliberately leaves alone:
|
|
5
|
+
// - a link with refusals reading green, because the old status bar keyed off
|
|
6
|
+
// `state` and never looked at `docs` at all;
|
|
7
|
+
// - the status bar and the connect note being able to disagree, because they
|
|
8
|
+
// were two rules over one payload.
|
|
9
|
+
//
|
|
10
|
+
// Every row of the plan's states/copy table gets a case, and each asserts the
|
|
11
|
+
// state names a NEXT ACTION where one exists — the user's explicit ask.
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
|
|
15
|
+
import { type SyncStatusLike, syncPresentation } from '../sync/presentation.ts';
|
|
16
|
+
|
|
17
|
+
const base: SyncStatusLike = {
|
|
18
|
+
state: 'online',
|
|
19
|
+
queuedOps: 0,
|
|
20
|
+
lastSyncAt: null,
|
|
21
|
+
offlineSince: null,
|
|
22
|
+
flash: null,
|
|
23
|
+
updatedAt: 0,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const at = (over: SyncStatusLike) =>
|
|
27
|
+
syncPresentation({ ...base, ...over }, { project: 'alligators' });
|
|
28
|
+
|
|
29
|
+
describe('nothing to say', () => {
|
|
30
|
+
test('an unlinked project has no hub status', () => {
|
|
31
|
+
expect(syncPresentation({ linked: false })).toBeNull();
|
|
32
|
+
expect(syncPresentation(null)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('the states a person passes through', () => {
|
|
37
|
+
test('connecting — no document has settled yet', () => {
|
|
38
|
+
const p = at({ state: 'connecting', docs: { synced: 0, pending: 3, rejected: 0 } });
|
|
39
|
+
expect(p?.phase).toBe('connecting');
|
|
40
|
+
expect(p?.online).toBe(false);
|
|
41
|
+
expect(p?.title).toContain('Connecting to alligators');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('syncing — visible progress, with a real count', () => {
|
|
45
|
+
const p = at({ docs: { synced: 40, pending: 35, rejected: 0 } });
|
|
46
|
+
expect(p?.phase).toBe('syncing');
|
|
47
|
+
expect(p?.online).toBe(false);
|
|
48
|
+
expect(p?.label).toBe('40/75');
|
|
49
|
+
expect(p?.title).toContain('40 of 75');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('synced — and names what arrived, without telling you to open it', () => {
|
|
53
|
+
const p = at({
|
|
54
|
+
docs: { synced: 75, pending: 0, rejected: 0 },
|
|
55
|
+
pulled: { names: ['ui-welcome', 'ui-how-to'], count: 2 },
|
|
56
|
+
});
|
|
57
|
+
expect(p?.phase).toBe('synced');
|
|
58
|
+
expect(p?.online).toBe(true);
|
|
59
|
+
expect(p?.title).toContain('2 came down');
|
|
60
|
+
// Names the fact; the decision to open hub-authored TSX stays the person's.
|
|
61
|
+
expect(p?.next).toContain('new to this machine');
|
|
62
|
+
expect(p?.next).not.toContain('Open one');
|
|
63
|
+
expect(p?.names).toEqual(['ui-welcome', 'ui-how-to']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('synced with nothing pulled has no next action — because there is none', () => {
|
|
67
|
+
const p = at({ docs: { synced: 5, pending: 0, rejected: 0 } });
|
|
68
|
+
expect(p?.phase).toBe('synced');
|
|
69
|
+
expect(p?.next).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('the states that used to read green', () => {
|
|
74
|
+
test('a hub that refuses everything is not synced', () => {
|
|
75
|
+
// THE surface defect: `state` is 'online' (a socket exists, the hub is
|
|
76
|
+
// reachable) while every document's edits go nowhere. The old status bar
|
|
77
|
+
// showed a green dot and the word "synced" here.
|
|
78
|
+
const p = at({ state: 'online', docs: { synced: 0, pending: 0, rejected: 75 } });
|
|
79
|
+
expect(p?.phase).toBe('refused');
|
|
80
|
+
expect(p?.online).toBe(false);
|
|
81
|
+
expect(p?.label).toBe('75 refused');
|
|
82
|
+
expect(p?.next).toContain('Reconnect');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a partial refusal is reported, not averaged away', () => {
|
|
86
|
+
const p = at({ state: 'online', docs: { synced: 72, pending: 0, rejected: 3 } });
|
|
87
|
+
expect(p?.phase).toBe('refused');
|
|
88
|
+
expect(p?.title).toContain('3 of 75');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('an unreachable hub outranks a stale count', () => {
|
|
92
|
+
const p = at({ state: 'offline', queuedOps: 4, docs: { synced: 75, pending: 0, rejected: 0 } });
|
|
93
|
+
expect(p?.phase).toBe('offline');
|
|
94
|
+
expect(p?.online).toBe(false);
|
|
95
|
+
expect(p?.label).toBe('offline · 4 ↑');
|
|
96
|
+
expect(p?.next).toContain('resumes by itself');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('payloads that predate the counts', () => {
|
|
101
|
+
test('an old payload falls back to state without inventing document facts', () => {
|
|
102
|
+
const p = at({ state: 'online', docs: undefined });
|
|
103
|
+
expect(p?.phase).toBe('synced');
|
|
104
|
+
expect(p?.title).not.toContain('canvas');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a linked project with zero known documents reads as connecting, not synced', () => {
|
|
108
|
+
const p = at({ state: 'online', docs: { synced: 0, pending: 0, rejected: 0 } });
|
|
109
|
+
expect(p?.phase).toBe('connecting');
|
|
110
|
+
expect(p?.online).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('nothing syncable keeps its own honest copy and names the move', () => {
|
|
114
|
+
const p = syncPresentation({ notSyncable: true, tsxCount: 4, reason: 'No canvases yet.' });
|
|
115
|
+
expect(p?.phase).toBe('nothing-syncable');
|
|
116
|
+
expect(p?.label).toBe('0 syncable · 4 tsx');
|
|
117
|
+
expect(p?.next).toContain('Create a canvas');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('a payload it cannot read is not a payload that says everything is fine', () => {
|
|
122
|
+
// `/_sync-status` returns JSON.parse of `_sync.json` with NO schema, so the
|
|
123
|
+
// input here is whatever is on disk — a partial write, an older producer, a
|
|
124
|
+
// newer one. `synced` used to be the fall-through, which made the reassuring
|
|
125
|
+
// answer the only reachable one for every shape not recognised.
|
|
126
|
+
test('an empty docs object does not render as "all NaN canvases", green', () => {
|
|
127
|
+
const p = at({ state: 'online', docs: {} as never });
|
|
128
|
+
expect(p?.phase).not.toBe('synced');
|
|
129
|
+
expect(p?.online).toBe(false);
|
|
130
|
+
expect(p?.title).not.toContain('NaN');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('negative, fractional and non-numeric counts all fail closed', () => {
|
|
134
|
+
for (const docs of [
|
|
135
|
+
{ synced: 5, pending: -5, rejected: 0 },
|
|
136
|
+
{ synced: 1, pending: null, rejected: 0 },
|
|
137
|
+
{ synced: 1.5, pending: 0, rejected: 0 },
|
|
138
|
+
{ synced: '75', pending: 0, rejected: 0 },
|
|
139
|
+
] as never[]) {
|
|
140
|
+
const p = at({ state: 'online', docs });
|
|
141
|
+
expect(p?.online).toBe(false);
|
|
142
|
+
expect(p?.phase).not.toBe('synced');
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('an absent docs field is still the old-payload path, not "unreadable"', () => {
|
|
147
|
+
// Absent and malformed are different facts and must not collapse.
|
|
148
|
+
const p = at({ state: 'online', docs: undefined });
|
|
149
|
+
expect(p?.phase).toBe('synced');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a wild pulled count cannot reach the sentence', () => {
|
|
153
|
+
const p = at({
|
|
154
|
+
docs: { synced: 1, pending: 0, rejected: 0 },
|
|
155
|
+
pulled: { names: [], count: 1e9 } as never,
|
|
156
|
+
});
|
|
157
|
+
expect(p?.title).not.toContain('1000000000');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('hub-supplied text is untrusted (DDR-054)', () => {
|
|
162
|
+
test('counts come first and names are capped in number', () => {
|
|
163
|
+
const p = at({
|
|
164
|
+
state: 'online',
|
|
165
|
+
docs: { synced: 0, pending: 0, rejected: 9 },
|
|
166
|
+
rejectedSlugs: ['a', 'b', 'c', 'd', 'e'],
|
|
167
|
+
});
|
|
168
|
+
expect(p?.names).toHaveLength(3);
|
|
169
|
+
// The label a hostile name could otherwise dominate carries no name at all.
|
|
170
|
+
expect(p?.label).toBe('9 refused');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('a long project name cannot run away with the sentence', () => {
|
|
174
|
+
const p = syncPresentation(
|
|
175
|
+
{ ...base, docs: { synced: 1, pending: 0, rejected: 0 } },
|
|
176
|
+
{ project: 'x'.repeat(500) }
|
|
177
|
+
);
|
|
178
|
+
expect(p?.title.length).toBeLessThan(200);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('names survive being absent or empty', () => {
|
|
182
|
+
const p = at({ state: 'online', docs: { synced: 0, pending: 0, rejected: 2 } });
|
|
183
|
+
expect(p?.names).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// The gap between what a project HOLDS and what this machine carries.
|
|
2
|
+
//
|
|
3
|
+
// Regression cover for the reported "Open in Maude does nothing": the desktop
|
|
4
|
+
// synced 72/72 and was accurate about the wrong set — three canvases lived only
|
|
5
|
+
// on the hub and no local counter could ever show it.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
describeRemoteDiff,
|
|
11
|
+
diffRemoteDocs,
|
|
12
|
+
fetchRemoteDocs,
|
|
13
|
+
pullTargets,
|
|
14
|
+
slugFromDocName,
|
|
15
|
+
} from '../sync/remote-docs.ts';
|
|
16
|
+
|
|
17
|
+
const okFetch = (documents: unknown) =>
|
|
18
|
+
(async () =>
|
|
19
|
+
new Response(JSON.stringify({ documents }), {
|
|
20
|
+
status: 200,
|
|
21
|
+
headers: { 'content-type': 'application/json' },
|
|
22
|
+
})) as unknown as typeof fetch;
|
|
23
|
+
|
|
24
|
+
describe('fetchRemoteDocs', () => {
|
|
25
|
+
test('reads the hub listing', async () => {
|
|
26
|
+
const docs = await fetchRemoteDocs(
|
|
27
|
+
'https://hub.example',
|
|
28
|
+
't',
|
|
29
|
+
okFetch([{ name: 'ui-welcome', bytes: 2931 }])
|
|
30
|
+
);
|
|
31
|
+
expect(docs).toEqual([{ name: 'ui-welcome', bytes: 2931 }]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('carries the token as a bearer, and strips a trailing slash', async () => {
|
|
35
|
+
let seenUrl = '';
|
|
36
|
+
let seenAuth = '';
|
|
37
|
+
const spy = (async (url: string, init: RequestInit) => {
|
|
38
|
+
seenUrl = String(url);
|
|
39
|
+
seenAuth = String((init.headers as Record<string, string>).authorization);
|
|
40
|
+
return new Response(JSON.stringify({ documents: [] }), { status: 200 });
|
|
41
|
+
}) as unknown as typeof fetch;
|
|
42
|
+
await fetchRemoteDocs('https://hub.example/', 'tok123', spy);
|
|
43
|
+
expect(seenUrl).toBe('https://hub.example/api/documents');
|
|
44
|
+
expect(seenAuth).toBe('Bearer tok123');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('an unreachable or old hub yields null, never a throw', async () => {
|
|
48
|
+
// Load-bearing: sync must proceed when the listing cannot be had. A hub
|
|
49
|
+
// without the route (404) must not degrade into a failed sync.
|
|
50
|
+
const boom = (async () => {
|
|
51
|
+
throw new Error('offline');
|
|
52
|
+
}) as unknown as typeof fetch;
|
|
53
|
+
expect(await fetchRemoteDocs('https://h', 't', boom)).toBeNull();
|
|
54
|
+
|
|
55
|
+
const notFound = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch;
|
|
56
|
+
expect(await fetchRemoteDocs('https://h', 't', notFound)).toBeNull();
|
|
57
|
+
|
|
58
|
+
const junk = (async () =>
|
|
59
|
+
new Response(JSON.stringify({ documents: 'not-an-array' }), {
|
|
60
|
+
status: 200,
|
|
61
|
+
})) as unknown as typeof fetch;
|
|
62
|
+
expect(await fetchRemoteDocs('https://h', 't', junk)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('diffRemoteDocs', () => {
|
|
67
|
+
test('names the canvases this machine can never receive', () => {
|
|
68
|
+
// The reported case, in miniature.
|
|
69
|
+
const diff = diffRemoteDocs(
|
|
70
|
+
['ui-home', 'ui-about'],
|
|
71
|
+
[
|
|
72
|
+
{ name: 'ui-home', bytes: 1 },
|
|
73
|
+
{ name: 'ui-about', bytes: 1 },
|
|
74
|
+
{ name: 'ui-welcome', bytes: 2931 },
|
|
75
|
+
]
|
|
76
|
+
);
|
|
77
|
+
expect(diff.shared).toEqual(['ui-about', 'ui-home']);
|
|
78
|
+
expect(diff.hubOnly.map((d) => d.name)).toEqual(['ui-welcome']);
|
|
79
|
+
expect(diff.localOnly).toEqual([]);
|
|
80
|
+
expect(diff.reachable).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a canvas created here but not yet pushed is localOnly, not a gap', () => {
|
|
84
|
+
const diff = diffRemoteDocs(['ui-new'], []);
|
|
85
|
+
expect(diff.localOnly).toEqual(['ui-new']);
|
|
86
|
+
expect(diff.hubOnly).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('an unreachable hub reports NOT reachable rather than a false gap', () => {
|
|
90
|
+
// Reporting every local canvas as "hub-only missing" would be worse than
|
|
91
|
+
// saying nothing — it would invent an alarm out of a network blip.
|
|
92
|
+
const diff = diffRemoteDocs(['ui-home'], null);
|
|
93
|
+
expect(diff.reachable).toBe(false);
|
|
94
|
+
expect(diff.hubOnly).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('compares WIRE names — a namespaced hub vs flat local is a real mismatch', () => {
|
|
98
|
+
// If a caller passed raw slugs against a namespaced hub, everything would
|
|
99
|
+
// look missing on both sides. The diff reports exactly that rather than
|
|
100
|
+
// papering over it, because it IS the doc-identity bug (DDR-192 §5).
|
|
101
|
+
const diff = diffRemoteDocs(['ui-home'], [{ name: 'ws/acme/main/ui-home', bytes: 1 }]);
|
|
102
|
+
expect(diff.shared).toEqual([]);
|
|
103
|
+
expect(diff.hubOnly.map((d) => d.name)).toEqual(['ws/acme/main/ui-home']);
|
|
104
|
+
expect(diff.localOnly).toEqual(['ui-home']);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('describeRemoteDiff', () => {
|
|
109
|
+
test('says what is missing, with names', () => {
|
|
110
|
+
const msg = describeRemoteDiff(
|
|
111
|
+
diffRemoteDocs(
|
|
112
|
+
[],
|
|
113
|
+
[
|
|
114
|
+
{ name: 'ui-welcome', bytes: 1 },
|
|
115
|
+
{ name: 'ui-how_to_use_maude', bytes: 1 },
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
expect(msg).toContain('2 canvases');
|
|
120
|
+
expect(msg).toContain('ui-welcome');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('stays quiet when there is nothing to report', () => {
|
|
124
|
+
expect(describeRemoteDiff(diffRemoteDocs(['a'], [{ name: 'a', bytes: 1 }]))).toBeNull();
|
|
125
|
+
expect(describeRemoteDiff(diffRemoteDocs(['a'], null))).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('pulling hub-only documents down', () => {
|
|
130
|
+
const join = (...p: string[]) => p.join('/');
|
|
131
|
+
const resolve = (p: string) => p.replace(/\/\.\//g, '/');
|
|
132
|
+
|
|
133
|
+
test('maps a flat and a namespaced doc name to the same slug', () => {
|
|
134
|
+
expect(slugFromDocName('ui-welcome')).toBe('ui-welcome');
|
|
135
|
+
expect(slugFromDocName('ws/acme/main/ui-welcome')).toBe('ui-welcome');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('lands hub-only canvases flat under the design root', () => {
|
|
139
|
+
const targets = pullTargets(
|
|
140
|
+
[
|
|
141
|
+
{ name: 'ui-welcome', bytes: 1 },
|
|
142
|
+
{ name: 'ws/acme/main/ui-home', bytes: 1 },
|
|
143
|
+
],
|
|
144
|
+
'/p/.design',
|
|
145
|
+
join,
|
|
146
|
+
resolve,
|
|
147
|
+
'/'
|
|
148
|
+
);
|
|
149
|
+
expect(targets.map((t) => t.bodyAbs)).toEqual([
|
|
150
|
+
'/p/.design/ui-welcome.tsx',
|
|
151
|
+
'/p/.design/ui-home.tsx',
|
|
152
|
+
]);
|
|
153
|
+
// The wire name is kept — the provider must open the document the HUB has,
|
|
154
|
+
// not a name re-derived from the local path.
|
|
155
|
+
expect(targets[1].docName).toBe('ws/acme/main/ui-home');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('a document NAME can never write outside the design root', () => {
|
|
159
|
+
// Hub-controlled input, last point before a create. Dots are refused
|
|
160
|
+
// outright, so traversal cannot even form a candidate path.
|
|
161
|
+
const evil = [
|
|
162
|
+
{ name: '../../etc/passwd', bytes: 1 },
|
|
163
|
+
{ name: '..', bytes: 1 },
|
|
164
|
+
{ name: 'ui/../../escape', bytes: 1 },
|
|
165
|
+
{ name: 'a b', bytes: 1 },
|
|
166
|
+
{ name: '', bytes: 1 },
|
|
167
|
+
{ name: 'x'.repeat(500), bytes: 1 },
|
|
168
|
+
];
|
|
169
|
+
expect(pullTargets(evil, '/p/.design', join, resolve, '/')).toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -23,8 +23,20 @@ describe('whats-new feed loader', () => {
|
|
|
23
23
|
}
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test('resolveMaudeVersion
|
|
26
|
+
test('resolveMaudeVersion resolves a real release version', () => {
|
|
27
27
|
expect(resolveMaudeVersion()).toMatch(/^\d+\.\d+\.\d+/);
|
|
28
|
+
// The studio's own manifest is preferred over the design plugin's (it is
|
|
29
|
+
// the one staged into the cell image), and `0.0.0` — the placeholder both
|
|
30
|
+
// carried before joining the release line — is never an answer.
|
|
31
|
+
expect(resolveMaudeVersion()).not.toBe('0.0.0');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('an unversioned root falls through rather than reporting the placeholder', () => {
|
|
35
|
+
const root = mkdtempSync(join(tmpdir(), 'maude-ver-'));
|
|
36
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify({ version: '0.0.0' }));
|
|
37
|
+
// No plugin manifest above a tmpdir either, so this lands on `dev` — a
|
|
38
|
+
// value obviously not a release, rather than a plausible wrong number.
|
|
39
|
+
expect(resolveMaudeVersion(root)).toBe('dev');
|
|
28
40
|
});
|
|
29
41
|
|
|
30
42
|
test('a missing feed dir yields empty entries and never throws', () => {
|
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "release-version-visible",
|
|
6
|
+
"version": "0.58.0",
|
|
7
|
+
"date": "2026-08-07",
|
|
8
|
+
"kind": "improvement",
|
|
9
|
+
"title": "See which version of Maude you are running",
|
|
10
|
+
"summary": "The Studio status bar now shows the running version, and the hub admin header shows it beside the bundle hash — so you can tell what you are on without digging. In the cloud it is the same chip, because the cloud serves the same Studio.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "cloud-connect-tells-you-what-happened",
|
|
15
|
+
"version": "0.57.0",
|
|
16
|
+
"date": "2026-08-07",
|
|
17
|
+
"kind": "fix",
|
|
18
|
+
"title": "Connecting a cloud project tells you what happened",
|
|
19
|
+
"summary": "The line you see after Connect is now live and true: it moves from Connecting, through Syncing 40 of 75, to Synced — and names any canvases that came down from the project. Every outcome says what to do next, so a refused connection tells you to reconnect instead of sitting on “syncing” forever.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "acp-turn-notifications",
|
|
24
|
+
"version": "0.57.0",
|
|
25
|
+
"date": "2026-08-07",
|
|
26
|
+
"kind": "feature",
|
|
27
|
+
"title": "Get notified when a background chat finishes",
|
|
28
|
+
"summary": "Maude now sends a real notification when a chat finishes or needs your input — even in a project you're not currently viewing. The notification never shows what was said, only which project needs you.",
|
|
29
|
+
"surface": "design-ui"
|
|
30
|
+
},
|
|
4
31
|
{
|
|
5
32
|
"id": "cloud-projects-save-themselves",
|
|
6
33
|
"version": "0.56.0",
|
package/apps/studio/whats-new.ts
CHANGED
|
@@ -43,18 +43,42 @@ export interface WhatsNewFeed {
|
|
|
43
43
|
let cache: WhatsNewFeed | null = null;
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* Read the installed maude version
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* Read the installed maude version.
|
|
47
|
+
*
|
|
48
|
+
* THE ONE resolution path (DDR-045: disk paths come from `paths.ts`). Everything
|
|
49
|
+
* that wants to name the running version — the What's New feed, `/_config`, the
|
|
50
|
+
* status-bar chip — calls this rather than growing a second answer that can
|
|
51
|
+
* disagree with the first.
|
|
52
|
+
*
|
|
53
|
+
* Two sources, in order:
|
|
54
|
+
*
|
|
55
|
+
* 1. the studio's OWN `package.json`. It rides the release line since the
|
|
56
|
+
* fleet-verification change, and it is the only one of the two that is
|
|
57
|
+
* staged into the cell image — a cloud tab asking "which version am I on"
|
|
58
|
+
* gets an answer rather than `dev`.
|
|
59
|
+
* 2. `plugins/design/.claude-plugin/plugin.json`, which ships in BOTH npm
|
|
60
|
+
* installs and marketplace-cache clones (same rationale as build.ts
|
|
61
|
+
* `readPluginVersion`). Kept as the fallback so an older layout that lacks
|
|
62
|
+
* a version-stamped studio manifest still resolves.
|
|
63
|
+
*
|
|
64
|
+
* Falls back to `dev` — a placeholder that is obviously not a release, never a
|
|
65
|
+
* plausible-looking wrong number.
|
|
50
66
|
*/
|
|
51
67
|
export function resolveMaudeVersion(root: string = DEV_SERVER_ROOT): string {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
const candidates = [
|
|
69
|
+
join(root, 'package.json'),
|
|
70
|
+
join(root, '..', '..', 'plugins', 'design', '.claude-plugin', 'plugin.json'),
|
|
71
|
+
];
|
|
72
|
+
for (const manifest of candidates) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { version?: unknown };
|
|
75
|
+
// `0.0.0` is the private-workspace placeholder these manifests carried
|
|
76
|
+
// before they joined the release line. Treating it as an answer is how
|
|
77
|
+
// `/health` came to report `0.0.0` for months.
|
|
78
|
+
if (typeof parsed.version === 'string' && parsed.version !== '0.0.0') return parsed.version;
|
|
79
|
+
} catch {
|
|
80
|
+
/* try the next one */
|
|
81
|
+
}
|
|
58
82
|
}
|
|
59
83
|
return 'dev';
|
|
60
84
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.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": {
|
|
@@ -54,13 +54,13 @@
|
|
|
54
54
|
"test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
|
|
55
55
|
},
|
|
56
56
|
"optionalDependencies": {
|
|
57
|
-
"@1agh/maude-darwin-arm64": "0.
|
|
58
|
-
"@1agh/maude-darwin-x64": "0.
|
|
59
|
-
"@1agh/maude-linux-arm64": "0.
|
|
60
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
61
|
-
"@1agh/maude-linux-x64": "0.
|
|
62
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
63
|
-
"@1agh/maude-win32-x64": "0.
|
|
57
|
+
"@1agh/maude-darwin-arm64": "0.58.0",
|
|
58
|
+
"@1agh/maude-darwin-x64": "0.58.0",
|
|
59
|
+
"@1agh/maude-linux-arm64": "0.58.0",
|
|
60
|
+
"@1agh/maude-linux-arm64-musl": "0.58.0",
|
|
61
|
+
"@1agh/maude-linux-x64": "0.58.0",
|
|
62
|
+
"@1agh/maude-linux-x64-musl": "0.58.0",
|
|
63
|
+
"@1agh/maude-win32-x64": "0.58.0"
|
|
64
64
|
},
|
|
65
65
|
"files": [
|
|
66
66
|
"cli",
|