@1agh/maude 0.49.2 → 0.50.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.
Files changed (34) hide show
  1. package/apps/studio/api.ts +751 -52
  2. package/apps/studio/canvas-artifacts.ts +153 -0
  3. package/apps/studio/canvas-create.ts +23 -0
  4. package/apps/studio/canvas-slug.ts +30 -0
  5. package/apps/studio/client/app.jsx +547 -90
  6. package/apps/studio/client/panels/CloudBar.jsx +284 -0
  7. package/apps/studio/client/styles/3-shell-maude.css +26 -0
  8. package/apps/studio/client/tree-row-menu.jsx +132 -0
  9. package/apps/studio/client/use-tree-drag.js +120 -0
  10. package/apps/studio/cloud/endpoints.ts +236 -0
  11. package/apps/studio/collab/registry.ts +23 -0
  12. package/apps/studio/dist/client.bundle.js +960 -958
  13. package/apps/studio/dist/styles.css +1 -1
  14. package/apps/studio/http.ts +113 -0
  15. package/apps/studio/inspect.ts +49 -0
  16. package/apps/studio/server.ts +15 -0
  17. package/apps/studio/test/canvas-artifacts.test.ts +125 -0
  18. package/apps/studio/test/canvas-create-api.test.ts +159 -1
  19. package/apps/studio/test/canvas-move-api.test.ts +556 -0
  20. package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
  21. package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
  22. package/apps/studio/whats-new.json +18 -0
  23. package/cli/bin/maude.mjs +1 -0
  24. package/cli/commands/hub-workspace.mjs +294 -7
  25. package/cli/commands/init.mjs +33 -2
  26. package/cli/commands/kg.mjs +129 -4
  27. package/cli/commands/share.mjs +184 -0
  28. package/cli/lib/ddr-to-kgai.mjs +310 -82
  29. package/cli/lib/ddr-to-kgai.test.mjs +20 -0
  30. package/cli/lib/share-plan.mjs +96 -0
  31. package/cli/lib/share-plan.test.mjs +57 -0
  32. package/cli/lib/workspace-plan.mjs +130 -12
  33. package/cli/lib/workspace-plan.test.mjs +160 -0
  34. package/package.json +8 -8
@@ -0,0 +1,236 @@
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
+
193
+ const workspaceUrl: string = opened.body.url;
194
+ // The cell is a different host than the control plane — one direct call.
195
+ let hubToken: string | null = null;
196
+ try {
197
+ const res = await fetch(`${workspaceUrl}/auth/login`, {
198
+ method: 'POST',
199
+ headers: { 'content-type': 'application/json' },
200
+ body: JSON.stringify({ token: opened.body.token }),
201
+ });
202
+ const body: any = await res.json().catch(() => ({}));
203
+ if (res.ok && body?.token) hubToken = body.token;
204
+ } catch {
205
+ /* handled below */
206
+ }
207
+ if (!hubToken) {
208
+ return { status: 502, json: { ok: false, error: 'The workspace did not accept the sign-in. Try again in a minute.' } };
209
+ }
210
+
211
+ const norm = normalizeUrl(workspaceUrl);
212
+ saveHubCredential(norm, hubToken);
213
+
214
+ // Project side — linkedHub in .design/config.json (committed; no token).
215
+ const cfgPath = join(ctx.paths.designRoot, 'config.json');
216
+ let cfg: any = {};
217
+ try {
218
+ cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
219
+ } catch {
220
+ /* absent/malformed → start minimal */
221
+ }
222
+ cfg.linkedHub = { url: norm, linkedAt: Date.now() };
223
+ writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
224
+
225
+ return {
226
+ status: 200,
227
+ json: {
228
+ ok: true,
229
+ url: norm,
230
+ role: opened.body.role,
231
+ note: 'Linked. Restart the studio server to start syncing.',
232
+ },
233
+ };
234
+ },
235
+ };
236
+ }
@@ -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
  }