@1agh/maude 0.49.2 → 0.51.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/apps/studio/api.ts +751 -52
- package/apps/studio/canvas-artifacts.ts +153 -0
- package/apps/studio/canvas-create.ts +23 -0
- package/apps/studio/canvas-slug.ts +30 -0
- package/apps/studio/client/app.jsx +547 -90
- package/apps/studio/client/panels/CloudBar.jsx +388 -0
- package/apps/studio/client/styles/3-shell-maude.css +28 -0
- package/apps/studio/client/tree-row-menu.jsx +132 -0
- package/apps/studio/client/use-tree-drag.js +120 -0
- package/apps/studio/cloud/endpoints.ts +284 -0
- package/apps/studio/collab/registry.ts +23 -0
- package/apps/studio/debug-bundle.ts +144 -0
- package/apps/studio/dist/client.bundle.js +1235 -1228
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +205 -1
- package/apps/studio/inspect.ts +49 -0
- package/apps/studio/server.ts +15 -0
- package/apps/studio/sync/workspace-signin.ts +15 -0
- package/apps/studio/test/canvas-artifacts.test.ts +125 -0
- package/apps/studio/test/canvas-create-api.test.ts +159 -1
- package/apps/studio/test/canvas-move-api.test.ts +556 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
- package/apps/studio/test/cloud-endpoints.test.ts +130 -0
- package/apps/studio/test/debug-bundle.test.ts +105 -0
- package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
- package/apps/studio/test/workspace-signin.test.ts +19 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/hub-workspace.mjs +294 -7
- package/cli/commands/init.mjs +33 -2
- package/cli/commands/kg.mjs +129 -4
- package/cli/commands/share.mjs +184 -0
- package/cli/lib/ddr-to-kgai.mjs +310 -82
- package/cli/lib/ddr-to-kgai.test.mjs +20 -0
- package/cli/lib/share-plan.mjs +96 -0
- package/cli/lib/share-plan.test.mjs +57 -0
- package/cli/lib/workspace-plan.mjs +130 -12
- package/cli/lib/workspace-plan.test.mjs +160 -0
- package/package.json +9 -8
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// use-tree-drag.js — feature-file-tree-drag-drop-folders (Task 8).
|
|
2
|
+
//
|
|
3
|
+
// Drag source/target bookkeeping for the file tree's HTML5 drag & drop layer:
|
|
4
|
+
// which canvas row is being dragged, which folder row is currently hovered
|
|
5
|
+
// (for the highlight + spring-load-open), and a small timer for the
|
|
6
|
+
// spring-load itself. Kept out of Tree/DirRow/CanvasRow so those components
|
|
7
|
+
// stay about rendering, not drag bookkeeping.
|
|
8
|
+
//
|
|
9
|
+
// Payload MIME is a custom `application/x-maude-canvas` (not `text/plain` or
|
|
10
|
+
// `Files`) so an OS file drag can't be mistaken for a tree-row drag, and the
|
|
11
|
+
// existing canvas-side media-drop path (use-canvas-media-drop.tsx) — which
|
|
12
|
+
// only reacts to real `DataTransfer.files` — stays unaffected.
|
|
13
|
+
|
|
14
|
+
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
15
|
+
|
|
16
|
+
export const TREE_DRAG_MIME = 'application/x-maude-canvas';
|
|
17
|
+
const SPRING_LOAD_MS = 600;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `onMove(fromPath, toDir)` — async, called on a valid drop. The caller owns
|
|
21
|
+
* the actual fetch + tree refresh; this hook only tracks UI state and decides
|
|
22
|
+
* when a drop is valid.
|
|
23
|
+
*/
|
|
24
|
+
export function useTreeDrag(onMove) {
|
|
25
|
+
const [draggedPath, setDraggedPath] = useState(null);
|
|
26
|
+
const [overDir, setOverDir] = useState(null);
|
|
27
|
+
const [busyPath, setBusyPath] = useState(null);
|
|
28
|
+
const springTimer = useRef(null);
|
|
29
|
+
|
|
30
|
+
const clearSpring = useCallback(() => {
|
|
31
|
+
if (springTimer.current) {
|
|
32
|
+
clearTimeout(springTimer.current);
|
|
33
|
+
springTimer.current = null;
|
|
34
|
+
}
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const dragProps = useMemo(
|
|
38
|
+
() => (path, draggable) => {
|
|
39
|
+
if (!draggable) return {};
|
|
40
|
+
return {
|
|
41
|
+
draggable: true,
|
|
42
|
+
onDragStart: (e) => {
|
|
43
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
44
|
+
e.dataTransfer.setData(TREE_DRAG_MIME, path);
|
|
45
|
+
// A lightweight ghost — the browser default drag image for a
|
|
46
|
+
// <button> row is poor (grabs the whole row incl. badges).
|
|
47
|
+
const ghost = document.createElement('div');
|
|
48
|
+
ghost.textContent = path.split('/').pop();
|
|
49
|
+
ghost.style.cssText =
|
|
50
|
+
'position:fixed;top:-1000px;left:-1000px;padding:4px 10px;' +
|
|
51
|
+
'background:var(--bg-2);color:var(--fg-1);border-radius:6px;' +
|
|
52
|
+
'font:12px system-ui;pointer-events:none;';
|
|
53
|
+
document.body.appendChild(ghost);
|
|
54
|
+
e.dataTransfer.setDragImage(ghost, 8, 8);
|
|
55
|
+
setTimeout(() => ghost.remove(), 0);
|
|
56
|
+
setDraggedPath(path);
|
|
57
|
+
},
|
|
58
|
+
onDragEnd: () => {
|
|
59
|
+
setDraggedPath(null);
|
|
60
|
+
setOverDir(null);
|
|
61
|
+
clearSpring();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
[clearSpring]
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/** `dirPath` is the drop target's full path; `openIfClosed` spring-loads it
|
|
69
|
+
* open after hovering. `valid` gates whether this target accepts drops at
|
|
70
|
+
* all (e.g. false for the design-system group). */
|
|
71
|
+
const dropProps = useMemo(
|
|
72
|
+
() => (dirPath, valid, openIfClosed) => {
|
|
73
|
+
if (!valid) return {};
|
|
74
|
+
return {
|
|
75
|
+
onDragOver: (e) => {
|
|
76
|
+
if (!e.dataTransfer.types.includes(TREE_DRAG_MIME)) return;
|
|
77
|
+
// Only preventDefault on a valid target — the cursor itself then
|
|
78
|
+
// communicates validity (no-drop glyph elsewhere).
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
e.dataTransfer.dropEffect = 'move';
|
|
81
|
+
if (overDir !== dirPath) {
|
|
82
|
+
setOverDir(dirPath);
|
|
83
|
+
clearSpring();
|
|
84
|
+
if (openIfClosed) {
|
|
85
|
+
springTimer.current = setTimeout(() => {
|
|
86
|
+
openIfClosed();
|
|
87
|
+
springTimer.current = null;
|
|
88
|
+
}, SPRING_LOAD_MS);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
onDragLeave: (e) => {
|
|
93
|
+
// Ignore bubbled leave events from child elements re-entering.
|
|
94
|
+
if (e.currentTarget.contains(e.relatedTarget)) return;
|
|
95
|
+
if (overDir === dirPath) setOverDir(null);
|
|
96
|
+
clearSpring();
|
|
97
|
+
},
|
|
98
|
+
onDrop: async (e) => {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
clearSpring();
|
|
101
|
+
setOverDir(null);
|
|
102
|
+
const fromPath = e.dataTransfer.getData(TREE_DRAG_MIME);
|
|
103
|
+
setDraggedPath(null);
|
|
104
|
+
if (!fromPath || fromPath === dirPath) return;
|
|
105
|
+
setBusyPath(fromPath);
|
|
106
|
+
try {
|
|
107
|
+
await onMove(fromPath, dirPath);
|
|
108
|
+
} finally {
|
|
109
|
+
setBusyPath(null);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
[overDir, clearSpring, onMove]
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// `move` — the raw callback, for the Task 9 keyboard path ("Move to…"),
|
|
118
|
+
// which doesn't go through a drop event at all.
|
|
119
|
+
return { draggedPath, overDir, busyPath, dragProps, dropProps, move: onMove };
|
|
120
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Maude Cloud from inside the studio — Cloud Phase 23 C3, the dev-server half.
|
|
2
|
+
//
|
|
3
|
+
// The desktop app (and a plain `maude design serve` in a browser) signs into
|
|
4
|
+
// Maude Cloud WITHOUT any Rust and without CORS on the platform: every call to
|
|
5
|
+
// cloud.maude.sh happens HERE, server-side, and the client only talks to the
|
|
6
|
+
// loopback `/_api/cloud/*` routes. The personal token lives next to the hub
|
|
7
|
+
// credentials the link flow already trusts (`~/.config/maude/`), mode 0600,
|
|
8
|
+
// never in the repo and never in the browser.
|
|
9
|
+
//
|
|
10
|
+
// The lane, end to end (every hop verified live against production):
|
|
11
|
+
// sign-in POST /auth/device/code → human approves on /activate → poll
|
|
12
|
+
// projects GET /api/projects (Bearer personal token)
|
|
13
|
+
// attach POST /projects/open → project token → the CELL exchanges it at
|
|
14
|
+
// POST /auth/login {token} (a POST body, never a URL) → a hub
|
|
15
|
+
// user token → saveHubCredential() + linkedHub in config.json —
|
|
16
|
+
// the exact state `maude design link` would have written by hand.
|
|
17
|
+
|
|
18
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { saveHubCredential } from '../sync/hub-link.ts';
|
|
23
|
+
import { normalizeUrl } from '../sync/hubs-config.ts';
|
|
24
|
+
|
|
25
|
+
const CLOUD_URL = (process.env.MAUDE_CLOUD_URL ?? 'https://cloud.maude.sh').replace(/\/+$/, '');
|
|
26
|
+
|
|
27
|
+
export interface CloudEndpointResult {
|
|
28
|
+
status: number;
|
|
29
|
+
json: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CloudFile {
|
|
33
|
+
url: string;
|
|
34
|
+
token: string;
|
|
35
|
+
email?: string;
|
|
36
|
+
connectedAt: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cloudConfigPath(): string {
|
|
40
|
+
return process.env.MAUDE_CLOUD_CONFIG ?? join(homedir(), '.config', 'maude', 'cloud.json');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readCloudFile(): CloudFile | null {
|
|
44
|
+
const p = cloudConfigPath();
|
|
45
|
+
if (!existsSync(p)) return null;
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
48
|
+
if (parsed && typeof parsed.token === 'string') return parsed as CloudFile;
|
|
49
|
+
} catch {
|
|
50
|
+
/* malformed → treat as signed out */
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeCloudFile(file: CloudFile): void {
|
|
56
|
+
const p = cloudConfigPath();
|
|
57
|
+
const dir = dirname(p);
|
|
58
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
59
|
+
writeFileSync(p, `${JSON.stringify(file, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
60
|
+
try {
|
|
61
|
+
chmodSync(p, 0o600);
|
|
62
|
+
} catch {
|
|
63
|
+
/* windows / read-only fs — best effort */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function cloudFetch(
|
|
68
|
+
path: string,
|
|
69
|
+
init: RequestInit = {},
|
|
70
|
+
{ timeoutMs = 15_000 } = {}
|
|
71
|
+
): Promise<{ status: number; body: any }> {
|
|
72
|
+
const ctl = new AbortController();
|
|
73
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(`${CLOUD_URL}${path}`, { ...init, signal: ctl.signal });
|
|
76
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return { status: 0, body: { error: (err as Error).message } };
|
|
79
|
+
} finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Ctx {
|
|
85
|
+
paths: { repoRoot: string; designRoot: string };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createCloudEndpoints(ctx: Ctx) {
|
|
89
|
+
return {
|
|
90
|
+
/** Signed in? Who? Cheap — reads the file, never the network. */
|
|
91
|
+
status(): CloudEndpointResult {
|
|
92
|
+
const file = readCloudFile();
|
|
93
|
+
return {
|
|
94
|
+
status: 200,
|
|
95
|
+
json: file
|
|
96
|
+
? { connected: true, email: file.email ?? null, url: file.url }
|
|
97
|
+
: { connected: false, url: CLOUD_URL },
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
/** Start the device flow: hand the client the human-facing half. */
|
|
102
|
+
async signinStart(): Promise<CloudEndpointResult> {
|
|
103
|
+
const r = await cloudFetch('/auth/device/code', {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'content-type': 'application/json' },
|
|
106
|
+
body: JSON.stringify({ client: `Maude Studio on ${process.platform}` }),
|
|
107
|
+
});
|
|
108
|
+
if (r.status !== 200) {
|
|
109
|
+
return { status: 502, json: { ok: false, error: 'Maude Cloud could not be reached. Try again in a moment.' } };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
status: 200,
|
|
113
|
+
json: {
|
|
114
|
+
ok: true,
|
|
115
|
+
userCode: r.body.user_code,
|
|
116
|
+
verificationUrl: r.body.verification_url,
|
|
117
|
+
deviceCode: r.body.device_code,
|
|
118
|
+
interval: r.body.interval ?? 5,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
/** One poll tick. On success the credential is stored server-side. */
|
|
124
|
+
async signinPoll(deviceCode: string): Promise<CloudEndpointResult> {
|
|
125
|
+
if (!deviceCode) return { status: 400, json: { ok: false, error: 'missing device code' } };
|
|
126
|
+
const r = await cloudFetch('/auth/device/token', {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'content-type': 'application/json' },
|
|
129
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
130
|
+
});
|
|
131
|
+
if (r.status === 202) return { status: 200, json: { ok: true, pending: true } };
|
|
132
|
+
if (r.status !== 200 || !r.body?.token) {
|
|
133
|
+
return { status: 410, json: { ok: false, error: 'The code expired. Start the sign-in again.' } };
|
|
134
|
+
}
|
|
135
|
+
writeCloudFile({
|
|
136
|
+
url: CLOUD_URL,
|
|
137
|
+
token: r.body.token,
|
|
138
|
+
email: r.body.account?.email ?? undefined,
|
|
139
|
+
connectedAt: Date.now(),
|
|
140
|
+
});
|
|
141
|
+
return { status: 200, json: { ok: true, pending: false, email: r.body.account?.email ?? null } };
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/** Forget the local credential. (Revocation lives on /account.) */
|
|
145
|
+
signout(): CloudEndpointResult {
|
|
146
|
+
try {
|
|
147
|
+
unlinkSync(cloudConfigPath());
|
|
148
|
+
} catch {
|
|
149
|
+
/* already gone */
|
|
150
|
+
}
|
|
151
|
+
return { status: 200, json: { ok: true } };
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
/** The signed-in account's projects, states included. */
|
|
155
|
+
async projects(): Promise<CloudEndpointResult> {
|
|
156
|
+
const file = readCloudFile();
|
|
157
|
+
if (!file) return { status: 401, json: { ok: false, error: 'Sign in to Maude Cloud first.' } };
|
|
158
|
+
const r = await cloudFetch('/api/projects', {
|
|
159
|
+
headers: { authorization: `Bearer ${file.token}` },
|
|
160
|
+
});
|
|
161
|
+
if (r.status === 401) {
|
|
162
|
+
// Revoked from the dashboard — honor it locally too.
|
|
163
|
+
this.signout();
|
|
164
|
+
return { status: 401, json: { ok: false, error: 'This device was disconnected. Sign in again.' } };
|
|
165
|
+
}
|
|
166
|
+
if (r.status !== 200) {
|
|
167
|
+
return { status: 502, json: { ok: false, error: 'Maude Cloud could not be reached.' } };
|
|
168
|
+
}
|
|
169
|
+
return { status: 200, json: { ok: true, projects: r.body.projects ?? [] } };
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Attach THIS project to a cloud workspace: open → exchange at the cell →
|
|
174
|
+
* store the hub credential + linkedHub, exactly as `maude design link`
|
|
175
|
+
* would. The sync agent picks the link up on the next server start.
|
|
176
|
+
*/
|
|
177
|
+
async attach(projectId: string): Promise<CloudEndpointResult> {
|
|
178
|
+
const file = readCloudFile();
|
|
179
|
+
if (!file) return { status: 401, json: { ok: false, error: 'Sign in to Maude Cloud first.' } };
|
|
180
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(String(projectId ?? ''))) {
|
|
181
|
+
return { status: 400, json: { ok: false, error: 'unknown project' } };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const opened = await cloudFetch('/projects/open', {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { authorization: `Bearer ${file.token}`, 'content-type': 'application/json' },
|
|
187
|
+
body: JSON.stringify({ project: projectId }),
|
|
188
|
+
});
|
|
189
|
+
if (opened.status !== 200 || !opened.body?.token) {
|
|
190
|
+
return { status: 502, json: { ok: false, error: 'The project could not be opened with this account.' } };
|
|
191
|
+
}
|
|
192
|
+
return linkToWorkspace({ workspaceUrl: opened.body.url, projectToken: opened.body.token, role: opened.body.role });
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Attach via a one-time handoff code — the maude:// lane (Phase 17). The
|
|
197
|
+
* code came from an untrusted URL, so it is exchanged ONLY against the
|
|
198
|
+
* configured Maude Cloud address (never one the link names), and the
|
|
199
|
+
* workspace we then link is the one THAT exchange returns. Needs no
|
|
200
|
+
* personal token: the code itself proves the dashboard session.
|
|
201
|
+
*/
|
|
202
|
+
async attachCode(code: string): Promise<CloudEndpointResult> {
|
|
203
|
+
if (typeof code !== 'string' || !/^mhc_[0-9a-f]{16,128}$/.test(code)) {
|
|
204
|
+
return { status: 400, json: { ok: false, error: 'That link is not valid.' } };
|
|
205
|
+
}
|
|
206
|
+
const exchanged = await cloudFetch('/auth/handoff/exchange', {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'content-type': 'application/json' },
|
|
209
|
+
body: JSON.stringify({ code }),
|
|
210
|
+
});
|
|
211
|
+
if (exchanged.status !== 200 || !exchanged.body?.token) {
|
|
212
|
+
return {
|
|
213
|
+
status: 410,
|
|
214
|
+
json: {
|
|
215
|
+
ok: false,
|
|
216
|
+
error:
|
|
217
|
+
'That link expired — it works once, for two minutes. Press “Open in Maude” on the project page again.',
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return linkToWorkspace({
|
|
222
|
+
workspaceUrl: exchanged.body.url,
|
|
223
|
+
projectToken: exchanged.body.token,
|
|
224
|
+
role: exchanged.body.role,
|
|
225
|
+
project: exchanged.body.project,
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** The shared tail of every attach: cell exchange → credential + linkedHub. */
|
|
231
|
+
async function linkToWorkspace({
|
|
232
|
+
workspaceUrl,
|
|
233
|
+
projectToken,
|
|
234
|
+
role,
|
|
235
|
+
project,
|
|
236
|
+
}: {
|
|
237
|
+
workspaceUrl: string;
|
|
238
|
+
projectToken: string;
|
|
239
|
+
role?: string;
|
|
240
|
+
project?: string;
|
|
241
|
+
}): Promise<CloudEndpointResult> {
|
|
242
|
+
// The cell is a different host than the control plane — one direct call.
|
|
243
|
+
let hubToken: string | null = null;
|
|
244
|
+
try {
|
|
245
|
+
const res = await fetch(`${workspaceUrl}/auth/login`, {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers: { 'content-type': 'application/json' },
|
|
248
|
+
body: JSON.stringify({ token: projectToken }),
|
|
249
|
+
});
|
|
250
|
+
const body: any = await res.json().catch(() => ({}));
|
|
251
|
+
if (res.ok && body?.token) hubToken = body.token;
|
|
252
|
+
} catch {
|
|
253
|
+
/* handled below */
|
|
254
|
+
}
|
|
255
|
+
if (!hubToken) {
|
|
256
|
+
return { status: 502, json: { ok: false, error: 'The workspace did not accept the sign-in. Try again in a minute.' } };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const norm = normalizeUrl(workspaceUrl);
|
|
260
|
+
saveHubCredential(norm, hubToken);
|
|
261
|
+
|
|
262
|
+
// Project side — linkedHub in .design/config.json (committed; no token).
|
|
263
|
+
const cfgPath = join(ctx.paths.designRoot, 'config.json');
|
|
264
|
+
let cfg: any = {};
|
|
265
|
+
try {
|
|
266
|
+
cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
267
|
+
} catch {
|
|
268
|
+
/* absent/malformed → start minimal */
|
|
269
|
+
}
|
|
270
|
+
cfg.linkedHub = { url: norm, linkedAt: Date.now() };
|
|
271
|
+
writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
status: 200,
|
|
275
|
+
json: {
|
|
276
|
+
ok: true,
|
|
277
|
+
url: norm,
|
|
278
|
+
role: role ?? null,
|
|
279
|
+
project: project ?? null,
|
|
280
|
+
note: 'Linked. Restart the studio server to start syncing.',
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -98,6 +98,16 @@ export interface Registry {
|
|
|
98
98
|
destroyAll(): Promise<void>;
|
|
99
99
|
/** Tear down a single room when its last peer leaves. */
|
|
100
100
|
drop(slug: string): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* feature-file-tree-drag-drop-folders (Task 3) — flush + tear down a room
|
|
103
|
+
* for a canvas move, REGARDLESS of live connections (unlike `drop`, which
|
|
104
|
+
* leaves an active room alone). The move already refused when
|
|
105
|
+
* `isPinned(slug)` is true (a hub provider owns the doc); this is a plain
|
|
106
|
+
* best-effort teardown so the rename doesn't leave a room keyed to a slug
|
|
107
|
+
* that no longer resolves to a file. A peer connected mid-move reconnects
|
|
108
|
+
* under the new slug via `get()`. No-op if no room is live for the slug.
|
|
109
|
+
*/
|
|
110
|
+
forceDrop(slug: string): Promise<void>;
|
|
101
111
|
/** Test/introspection. */
|
|
102
112
|
size(): number;
|
|
103
113
|
}
|
|
@@ -225,6 +235,18 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
|
|
|
225
235
|
await room.destroy();
|
|
226
236
|
}
|
|
227
237
|
|
|
238
|
+
async function forceDrop(slug: string): Promise<void> {
|
|
239
|
+
const room = rooms.get(slug);
|
|
240
|
+
if (!room) return;
|
|
241
|
+
// Safety net — the caller (moveCanvas) already refuses the move when
|
|
242
|
+
// pinned, but never yank a doc out from under an attached hub provider.
|
|
243
|
+
if (pinned.has(slug)) return;
|
|
244
|
+
await room.flush();
|
|
245
|
+
teardownBridge(slug);
|
|
246
|
+
rooms.delete(slug);
|
|
247
|
+
await room.destroy();
|
|
248
|
+
}
|
|
249
|
+
|
|
228
250
|
async function destroyAll(): Promise<void> {
|
|
229
251
|
for (const slug of Array.from(bridges.keys())) teardownBridge(slug);
|
|
230
252
|
hubAwareness.clear();
|
|
@@ -247,6 +269,7 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
|
|
|
247
269
|
flushAll,
|
|
248
270
|
destroyAll,
|
|
249
271
|
drop,
|
|
272
|
+
forceDrop,
|
|
250
273
|
size: () => rooms.size,
|
|
251
274
|
};
|
|
252
275
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// debug-bundle.ts — scrubbed diagnostic bundle for the Report-a-Bug dialog
|
|
2
|
+
// (feature-bug-report-button). Three pieces:
|
|
3
|
+
//
|
|
4
|
+
// 1. a 500-line in-memory log ring fed by a console tap (installLogRing) —
|
|
5
|
+
// the dev-server has no persistent log of its own, so the ring is what
|
|
6
|
+
// gives a report something to attach;
|
|
7
|
+
// 2. scrub() — the DETERMINISTIC redaction pass every log line goes through
|
|
8
|
+
// before it can leave the process. Privacy invariant (docs/report-schema.md):
|
|
9
|
+
// the scrubber runs server-side, has red/green tests, and is extended
|
|
10
|
+
// test-first — never "best-effort";
|
|
11
|
+
// 3. buildDebugBundle() — assembles the `maude-report/v1` server-side fields.
|
|
12
|
+
//
|
|
13
|
+
// The consuming route (`GET /_api/debug-bundle`, http.ts) is PRIVILEGED:
|
|
14
|
+
// main-origin only, absent from CANVAS_SAFE_API + startCanvasServer's routes
|
|
15
|
+
// (dual-allowlist rule, DDR-088) — a hub-pushed canvas must never read logs.
|
|
16
|
+
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
|
|
19
|
+
const RING_MAX = 500;
|
|
20
|
+
const ring: string[] = [];
|
|
21
|
+
let tapInstalled = false;
|
|
22
|
+
|
|
23
|
+
/** Append one line to the ring (public for tests + non-console callers). */
|
|
24
|
+
export function recordLogLine(line: string): void {
|
|
25
|
+
ring.push(line);
|
|
26
|
+
if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Last `n` raw (UNSCRUBBED — caller must scrub) ring lines, oldest first. */
|
|
30
|
+
export function logRingLines(n = 200): string[] {
|
|
31
|
+
return ring.slice(-n);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fmt(args: unknown[]): string {
|
|
35
|
+
return args
|
|
36
|
+
.map((a) => {
|
|
37
|
+
if (typeof a === 'string') return a;
|
|
38
|
+
if (a instanceof Error) return a.stack ?? a.message;
|
|
39
|
+
try {
|
|
40
|
+
return JSON.stringify(a);
|
|
41
|
+
} catch {
|
|
42
|
+
return String(a);
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
.join(' ');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mirror console.log/warn/error into the ring. Original behavior preserved
|
|
50
|
+
* (stdout/stderr still get every line). Idempotent — safe to call from both
|
|
51
|
+
* the server boot and tests.
|
|
52
|
+
*/
|
|
53
|
+
export function installLogRing(): void {
|
|
54
|
+
if (tapInstalled) return;
|
|
55
|
+
tapInstalled = true;
|
|
56
|
+
for (const level of ['log', 'warn', 'error'] as const) {
|
|
57
|
+
const original = console[level].bind(console);
|
|
58
|
+
console[level] = (...args: unknown[]) => {
|
|
59
|
+
recordLogLine(`[${level}] ${fmt(args)}`);
|
|
60
|
+
original(...args);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ScrubOptions {
|
|
66
|
+
/** Absolute repo root → rewritten to `<project>/`. */
|
|
67
|
+
repoRoot?: string;
|
|
68
|
+
/** Home dir → rewritten to `~`. Defaults to os.homedir(). */
|
|
69
|
+
home?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Deterministic redaction. Order matters: repoRoot first (it usually lives
|
|
74
|
+
* under home — scrubbing home first would leave `~/git/repo` variants that
|
|
75
|
+
* the repoRoot rule no longer matches), then home, then secret material,
|
|
76
|
+
* then emails.
|
|
77
|
+
*/
|
|
78
|
+
export function scrub(text: string, opts: ScrubOptions = {}): string {
|
|
79
|
+
let out = text;
|
|
80
|
+
|
|
81
|
+
const repoRoot = opts.repoRoot?.replace(/\/+$/, '');
|
|
82
|
+
if (repoRoot) out = out.replaceAll(repoRoot, '<project>');
|
|
83
|
+
|
|
84
|
+
const home = (opts.home ?? homedir()).replace(/\/+$/, '');
|
|
85
|
+
if (home) out = out.replaceAll(home, '~');
|
|
86
|
+
|
|
87
|
+
// Known token shapes first (GitHub classic + fine-grained, Anthropic, JWTs).
|
|
88
|
+
out = out.replace(/\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{8,}\b/g, '[redacted]');
|
|
89
|
+
out = out.replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, '[redacted]');
|
|
90
|
+
out = out.replace(
|
|
91
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g,
|
|
92
|
+
'[redacted]'
|
|
93
|
+
);
|
|
94
|
+
// Bearer headers, however they were logged.
|
|
95
|
+
out = out.replace(/\b(Bearer)\s+[^\s"']+/gi, '$1 [redacted]');
|
|
96
|
+
// key=value / "key": "value" forms for secret-shaped keys — keep the key,
|
|
97
|
+
// redact the value.
|
|
98
|
+
out = out.replace(
|
|
99
|
+
/\b(token|secret|password|passwd|api[_-]?key|authorization|access[_-]?key)("?\s*[:=]\s*"?)[^\s"',}]+/gi,
|
|
100
|
+
'$1$2[redacted]'
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
out = out.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '[email]');
|
|
104
|
+
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface DebugBundle {
|
|
109
|
+
app: { maudeVersion: string; platform: string; arch: string };
|
|
110
|
+
context: { projectName: string | null; activeCanvas: string | null };
|
|
111
|
+
logs: { serverLogTail: string };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Assemble the server-side half of a `maude-report/v1` payload. Everything
|
|
116
|
+
* here is ALREADY scrubbed — the client treats the bundle as display+consent
|
|
117
|
+
* material and must not need to re-clean it. Canvas CONTENT never enters the
|
|
118
|
+
* bundle: `activeCanvas` is the relative path only.
|
|
119
|
+
*/
|
|
120
|
+
export function buildDebugBundle(args: {
|
|
121
|
+
maudeVersion: string;
|
|
122
|
+
projectName: string | null;
|
|
123
|
+
activeCanvas: string | null;
|
|
124
|
+
repoRoot: string;
|
|
125
|
+
tailLines?: number;
|
|
126
|
+
}): DebugBundle {
|
|
127
|
+
const scrubOpts: ScrubOptions = { repoRoot: args.repoRoot };
|
|
128
|
+
return {
|
|
129
|
+
app: {
|
|
130
|
+
maudeVersion: args.maudeVersion,
|
|
131
|
+
platform: process.platform,
|
|
132
|
+
arch: process.arch,
|
|
133
|
+
},
|
|
134
|
+
context: {
|
|
135
|
+
projectName: args.projectName,
|
|
136
|
+
activeCanvas: args.activeCanvas ? scrub(args.activeCanvas, scrubOpts) : null,
|
|
137
|
+
},
|
|
138
|
+
logs: {
|
|
139
|
+
serverLogTail: logRingLines(args.tailLines ?? 200)
|
|
140
|
+
.map((l) => scrub(l, scrubOpts))
|
|
141
|
+
.join('\n'),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|