fluidcad 0.0.34 → 0.0.36
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 +69 -0
- package/bin/commands/login.js +148 -0
- package/bin/commands/mcp.js +3 -2
- package/bin/commands/pack.js +49 -0
- package/bin/commands/publish.js +231 -0
- package/bin/fluidcad.js +6 -0
- package/bin/lib/api-client.js +48 -0
- package/bin/lib/browser.js +16 -0
- package/bin/lib/config.js +39 -0
- package/bin/lib/model-config.js +61 -0
- package/bin/lib/prompt.js +97 -0
- package/bin/lib/workspace.js +57 -0
- package/lib/dist/common/shape-factory.d.ts +2 -1
- package/lib/dist/common/shape-factory.js +4 -0
- package/lib/dist/common/transformable-primitive.d.ts +6 -5
- package/lib/dist/common/transformable-primitive.js +8 -7
- package/lib/dist/common/vertex.js +0 -1
- package/lib/dist/core/2d/aline.d.ts +4 -3
- package/lib/dist/core/2d/aline.js +3 -2
- package/lib/dist/core/2d/arc.d.ts +3 -2
- package/lib/dist/core/2d/arc.js +4 -3
- package/lib/dist/core/2d/bezier.d.ts +8 -6
- package/lib/dist/core/2d/circle.d.ts +4 -3
- package/lib/dist/core/2d/circle.js +3 -2
- package/lib/dist/core/2d/ellipse.d.ts +5 -4
- package/lib/dist/core/2d/ellipse.js +5 -4
- package/lib/dist/core/2d/hline.d.ts +4 -3
- package/lib/dist/core/2d/hline.js +5 -3
- package/lib/dist/core/2d/line.js +1 -0
- package/lib/dist/core/2d/offset.d.ts +3 -2
- package/lib/dist/core/2d/offset.js +6 -5
- package/lib/dist/core/2d/polygon.d.ts +5 -4
- package/lib/dist/core/2d/polygon.js +10 -9
- package/lib/dist/core/2d/rect.d.ts +4 -3
- package/lib/dist/core/2d/rect.js +10 -9
- package/lib/dist/core/2d/slot.d.ts +14 -6
- package/lib/dist/core/2d/slot.js +19 -8
- package/lib/dist/core/2d/vline.d.ts +4 -3
- package/lib/dist/core/2d/vline.js +5 -3
- package/lib/dist/core/chamfer.d.ts +5 -4
- package/lib/dist/core/chamfer.js +7 -6
- package/lib/dist/core/color.d.ts +3 -2
- package/lib/dist/core/color.js +2 -1
- package/lib/dist/core/cut.d.ts +4 -3
- package/lib/dist/core/cut.js +5 -4
- package/lib/dist/core/cylinder.d.ts +2 -1
- package/lib/dist/core/cylinder.js +2 -1
- package/lib/dist/core/draft.d.ts +3 -2
- package/lib/dist/core/draft.js +3 -2
- package/lib/dist/core/extrude.d.ts +4 -3
- package/lib/dist/core/extrude.js +5 -4
- package/lib/dist/core/fillet.d.ts +5 -4
- package/lib/dist/core/fillet.js +6 -5
- package/lib/dist/core/index.d.ts +1 -0
- package/lib/dist/core/index.js +1 -0
- package/lib/dist/core/interfaces.d.ts +25 -24
- package/lib/dist/core/param.d.ts +74 -0
- package/lib/dist/core/param.js +147 -0
- package/lib/dist/core/repeat.d.ts +2 -1
- package/lib/dist/core/repeat.js +10 -8
- package/lib/dist/core/revolve.d.ts +2 -1
- package/lib/dist/core/revolve.js +3 -2
- package/lib/dist/core/rib.d.ts +3 -2
- package/lib/dist/core/rib.js +6 -2
- package/lib/dist/core/rotate.d.ts +5 -4
- package/lib/dist/core/rotate.js +4 -3
- package/lib/dist/core/shell.d.ts +3 -2
- package/lib/dist/core/shell.js +3 -2
- package/lib/dist/core/sphere.d.ts +3 -2
- package/lib/dist/core/sphere.js +2 -1
- package/lib/dist/core/translate.d.ts +7 -6
- package/lib/dist/core/translate.js +6 -5
- package/lib/dist/features/2d/arc.js +5 -5
- package/lib/dist/features/2d/bezier.js +16 -16
- package/lib/dist/features/2d/circle.js +4 -0
- package/lib/dist/features/2d/ellipse.js +4 -0
- package/lib/dist/features/2d/hline.d.ts +3 -0
- package/lib/dist/features/2d/hline.js +9 -2
- package/lib/dist/features/2d/line.d.ts +3 -0
- package/lib/dist/features/2d/line.js +11 -3
- package/lib/dist/features/2d/sketch.js +5 -1
- package/lib/dist/features/2d/slot.d.ts +5 -0
- package/lib/dist/features/2d/slot.js +52 -7
- package/lib/dist/features/2d/tarc-to-point-tangent.js +3 -0
- package/lib/dist/features/2d/tarc-to-point.js +3 -0
- package/lib/dist/features/2d/tarc-with-tangent.js +3 -0
- package/lib/dist/features/2d/tarc.js +3 -0
- package/lib/dist/features/2d/vline.d.ts +3 -0
- package/lib/dist/features/2d/vline.js +9 -2
- package/lib/dist/features/copy-circular.d.ts +4 -3
- package/lib/dist/features/copy-circular.js +16 -9
- package/lib/dist/features/copy-circular2d.js +16 -9
- package/lib/dist/features/copy-linear.d.ts +4 -3
- package/lib/dist/features/copy-linear.js +18 -12
- package/lib/dist/features/copy-linear2d.js +18 -12
- package/lib/dist/features/extrude-base.d.ts +4 -3
- package/lib/dist/features/extrude-base.js +10 -3
- package/lib/dist/features/mirror-shape2d.js +2 -2
- package/lib/dist/features/repeat-base.d.ts +13 -0
- package/lib/dist/features/repeat-base.js +21 -0
- package/lib/dist/features/repeat-circular.d.ts +6 -5
- package/lib/dist/features/repeat-circular.js +3 -6
- package/lib/dist/features/repeat-linear.d.ts +7 -7
- package/lib/dist/features/repeat-linear.js +3 -6
- package/lib/dist/index.d.ts +5 -0
- package/lib/dist/index.js +8 -1
- package/lib/dist/io/file-import.d.ts +7 -0
- package/lib/dist/io/file-import.js +30 -10
- package/lib/dist/math/lazy-matrix.d.ts +5 -0
- package/lib/dist/math/lazy-matrix.js +78 -10
- package/lib/dist/oc/boolean-ops.d.ts +2 -2
- package/lib/dist/param-registry.d.ts +34 -0
- package/lib/dist/param-registry.js +60 -0
- package/lib/dist/rendering/mesh-builder.js +2 -1
- package/lib/dist/tests/features/copy-circular.test.js +1 -1
- package/lib/dist/tests/features/copy-linear.test.js +10 -10
- package/lib/dist/tests/features/repeat-user-repro-cache.test.d.ts +1 -0
- package/lib/dist/tests/features/repeat-user-repro-cache.test.js +97 -0
- package/lib/dist/tsconfig.tsbuildinfo +1 -1
- package/llm-docs/api/bezier.md +10 -11
- package/llm-docs/api/index.json +1 -1
- package/llm-docs/api/types/arc-points.md +2 -2
- package/llm-docs/api/types/cut.md +10 -10
- package/llm-docs/api/types/extrude.md +10 -10
- package/llm-docs/api/types/loft.md +6 -6
- package/llm-docs/api/types/revolve.md +6 -6
- package/llm-docs/api/types/rib.md +2 -2
- package/llm-docs/api/types/slot.md +2 -2
- package/llm-docs/api/types/sweep.md +10 -10
- package/llm-docs/api/types/transformable.md +14 -14
- package/llm-docs/index.json +12 -12
- package/mcp/dist/client.d.ts +1 -0
- package/mcp/dist/client.js +8 -1
- package/mcp/dist/server.js +14 -1
- package/mcp/dist/tools/engine.d.ts +16 -0
- package/mcp/dist/tools/engine.js +45 -0
- package/package.json +9 -3
- package/server/dist/api.d.ts +37 -0
- package/server/dist/api.js +44 -0
- package/server/dist/code-editor.d.ts +64 -0
- package/server/dist/code-editor.js +520 -2
- package/server/dist/fluidcad-server.d.ts +87 -1
- package/server/dist/fluidcad-server.js +254 -88
- package/server/dist/host/blocked-imports.d.ts +8 -0
- package/server/dist/host/blocked-imports.js +30 -0
- package/server/dist/{vite-manager.d.ts → host/local-scene-host.d.ts} +3 -1
- package/server/dist/{vite-manager.js → host/local-scene-host.js} +6 -26
- package/server/dist/host/scene-host.d.ts +19 -0
- package/server/dist/host/scene-host.js +1 -0
- package/server/dist/index.js +24 -117
- package/server/dist/model-package/capture-params.d.ts +19 -0
- package/server/dist/model-package/capture-params.js +42 -0
- package/server/dist/model-package/pack.d.ts +23 -0
- package/server/dist/model-package/pack.js +230 -0
- package/server/dist/model-package/types.d.ts +79 -0
- package/server/dist/model-package/types.js +17 -0
- package/server/dist/routes/hit-test.d.ts +3 -0
- package/server/dist/routes/hit-test.js +17 -0
- package/server/dist/routes/pack.d.ts +10 -0
- package/server/dist/routes/pack.js +47 -0
- package/server/dist/routes/params.d.ts +3 -0
- package/server/dist/routes/params.js +75 -0
- package/server/dist/routes/sketch-edits.d.ts +3 -0
- package/server/dist/routes/sketch-edits.js +542 -0
- package/server/dist/routes/timeline.d.ts +3 -0
- package/server/dist/routes/timeline.js +49 -0
- package/server/dist/server-core.d.ts +53 -0
- package/server/dist/server-core.js +147 -0
- package/server/dist/ws-protocol.d.ts +101 -2
- package/ui/dist/assets/index-CDJmUpFI.css +2 -0
- package/ui/dist/assets/index-MRqwG9Vh.js +5417 -0
- package/ui/dist/index.html +2 -2
- package/server/dist/routes/actions.d.ts +0 -3
- package/server/dist/routes/actions.js +0 -309
- package/ui/dist/assets/index-BdqrMDRu.js +0 -4946
- package/ui/dist/assets/index-DR7c2Qk9.css +0 -2
package/README.md
CHANGED
|
@@ -115,6 +115,11 @@ Import and export STEP files with full color support. Bring in existing CAD mode
|
|
|
115
115
|
|
|
116
116
|
FluidCAD ships official extensions for **VS Code** and **Neovim**, but works with any editor -- just point the CLI at your project.
|
|
117
117
|
|
|
118
|
+
### LLM / AI Agent Integration (MCP)
|
|
119
|
+
|
|
120
|
+
FluidCAD ships an [MCP](https://modelcontextprotocol.io) server so AI agents (Claude Code, Claude Desktop, Cursor, opencode, etc.) can drive a running workspace -- take screenshots, inspect geometry, edit `.fluid.js` files, and look up the API by symbol. See [Set Up the MCP Server](#3-optional-set-up-the-mcp-server) below.
|
|
121
|
+
|
|
122
|
+
|
|
118
123
|
---
|
|
119
124
|
|
|
120
125
|
## Tutorials
|
|
@@ -234,6 +239,70 @@ This starts a local server and opens a 3D viewport in your browser. Edit your `.
|
|
|
234
239
|
|
|
235
240
|
</details>
|
|
236
241
|
|
|
242
|
+
### 3. (Optional) Set Up the MCP Server
|
|
243
|
+
|
|
244
|
+
FluidCAD bundles an [MCP](https://modelcontextprotocol.io) server so LLM agents can drive your workspace -- screenshots, geometry inspection, source edits, API lookup. It's included in the `fluidcad` package; no separate install needed.
|
|
245
|
+
|
|
246
|
+
Wire it into your MCP client:
|
|
247
|
+
|
|
248
|
+
<details>
|
|
249
|
+
<summary><strong>Claude Code</strong></summary>
|
|
250
|
+
|
|
251
|
+
Register at user scope so it's available in every project:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
claude mcp add --scope user FluidCAD -- npx -y fluidcad mcp
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
</details>
|
|
258
|
+
|
|
259
|
+
<details>
|
|
260
|
+
<summary><strong>Claude Desktop / Cursor</strong></summary>
|
|
261
|
+
|
|
262
|
+
Add to `claude_desktop_config.json` or `~/.cursor/mcp.json`:
|
|
263
|
+
|
|
264
|
+
```json
|
|
265
|
+
{
|
|
266
|
+
"mcpServers": {
|
|
267
|
+
"FluidCAD": {
|
|
268
|
+
"command": "npx",
|
|
269
|
+
"args": ["-y", "fluidcad", "mcp"]
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
</details>
|
|
276
|
+
|
|
277
|
+
<details>
|
|
278
|
+
<summary><strong>opencode</strong></summary>
|
|
279
|
+
|
|
280
|
+
Run `opencode mcp add` and answer the prompts, or add to `~/.config/opencode/opencode.json`:
|
|
281
|
+
|
|
282
|
+
```json
|
|
283
|
+
{
|
|
284
|
+
"$schema": "https://opencode.ai/config.json",
|
|
285
|
+
"mcp": {
|
|
286
|
+
"FluidCAD": {
|
|
287
|
+
"type": "local",
|
|
288
|
+
"command": ["npx", "-y", "fluidcad", "mcp"],
|
|
289
|
+
"enabled": true
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
</details>
|
|
296
|
+
|
|
297
|
+
Then install the companion skill so agents follow the FluidCAD workflow:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
npx skills add Fluid-CAD/FluidCAD
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
See the [MCP README](mcp/README.md) for the full tool surface, transport details, and local-testing guide.
|
|
304
|
+
|
|
305
|
+
|
|
237
306
|
---
|
|
238
307
|
|
|
239
308
|
## License
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import { randomBytes, createHash } from 'crypto';
|
|
4
|
+
import { getHubUrl, writeCredentials } from '../lib/config.js';
|
|
5
|
+
import { HubClient } from '../lib/api-client.js';
|
|
6
|
+
import { readPackageVersion } from '../lib/workspace.js';
|
|
7
|
+
import { openBrowser } from '../lib/browser.js';
|
|
8
|
+
|
|
9
|
+
const CLIENT_ID = 'fluidcad-cli';
|
|
10
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
function base64url(buf) {
|
|
13
|
+
return buf.toString('base64url');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const PLATFORM_NAMES = { darwin: 'macOS', win32: 'Windows', linux: 'Linux' };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Human-readable device hints shown on the hub's authorize page so the user can
|
|
20
|
+
* confirm the request came from this machine. Display-only — the hub never uses
|
|
21
|
+
* them for the authorization decision (PKCE + loopback do that). The browser
|
|
22
|
+
* request already reveals OS/arch via its User-Agent, so this leaks nothing new.
|
|
23
|
+
*/
|
|
24
|
+
function deviceHints() {
|
|
25
|
+
const platform = PLATFORM_NAMES[process.platform] ?? process.platform;
|
|
26
|
+
// os.release() is the kernel/Darwin/NT version; the leading x.y.z is the
|
|
27
|
+
// useful part (e.g. "7.0.10-201.fc44.x86_64" → "7.0.10").
|
|
28
|
+
const release = os.release().split('-')[0];
|
|
29
|
+
return {
|
|
30
|
+
os: `${platform} ${release}`.trim(),
|
|
31
|
+
arch: process.arch,
|
|
32
|
+
version: readPackageVersion(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Loopback + PKCE login (RFC 8252): spin up a one-shot 127.0.0.1 server, send
|
|
38
|
+
* the browser to the hub's /cli/authorize, receive the code on /callback,
|
|
39
|
+
* exchange it (with the PKCE verifier) for a token, and save it.
|
|
40
|
+
*/
|
|
41
|
+
function runLogin(opts) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const hubUrl = getHubUrl(opts.hub);
|
|
44
|
+
const verifier = base64url(randomBytes(32));
|
|
45
|
+
const challenge = base64url(createHash('sha256').update(verifier).digest());
|
|
46
|
+
const state = base64url(randomBytes(16));
|
|
47
|
+
let redirectUri;
|
|
48
|
+
let settled = false;
|
|
49
|
+
|
|
50
|
+
const finish = (fn, arg) => {
|
|
51
|
+
if (settled) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
settled = true;
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
server.close();
|
|
57
|
+
fn(arg);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const server = http.createServer((req, res) => {
|
|
61
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
62
|
+
if (url.pathname !== '/callback') {
|
|
63
|
+
res.writeHead(404);
|
|
64
|
+
res.end();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const error = url.searchParams.get('error');
|
|
68
|
+
const code = url.searchParams.get('code');
|
|
69
|
+
const returnedState = url.searchParams.get('state');
|
|
70
|
+
|
|
71
|
+
// The code has now been received here on the loopback; hand the browser
|
|
72
|
+
// to the hub's branded result screen (nav + design system) to finish.
|
|
73
|
+
const doneStatus = error ? 'denied' : code ? 'ok' : 'error';
|
|
74
|
+
res.writeHead(302, { location: `${hubUrl}/cli/done?status=${doneStatus}` });
|
|
75
|
+
res.end();
|
|
76
|
+
|
|
77
|
+
(async () => {
|
|
78
|
+
if (error) {
|
|
79
|
+
throw new Error(`authorization ${error}`);
|
|
80
|
+
}
|
|
81
|
+
if (returnedState !== state) {
|
|
82
|
+
throw new Error('state mismatch — aborting (possible CSRF)');
|
|
83
|
+
}
|
|
84
|
+
if (!code) {
|
|
85
|
+
throw new Error('no authorization code in callback');
|
|
86
|
+
}
|
|
87
|
+
const { status, body } = await new HubClient(hubUrl).postJson('/api/cli/token', {
|
|
88
|
+
client_id: CLIENT_ID,
|
|
89
|
+
code,
|
|
90
|
+
code_verifier: verifier,
|
|
91
|
+
redirect_uri: redirectUri,
|
|
92
|
+
});
|
|
93
|
+
if (status !== 200 || !body.access_token) {
|
|
94
|
+
throw new Error(`token exchange failed: ${body.error_description || body.error || `HTTP ${status}`}`);
|
|
95
|
+
}
|
|
96
|
+
writeCredentials({ token: body.access_token, email: body.user?.email ?? null, hubUrl });
|
|
97
|
+
return body.user?.email ?? null;
|
|
98
|
+
})().then(
|
|
99
|
+
(email) => finish(resolve, email),
|
|
100
|
+
(err) => finish(reject, err),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const timer = setTimeout(
|
|
105
|
+
() => finish(reject, new Error('login timed out — no response within 5 minutes')),
|
|
106
|
+
LOGIN_TIMEOUT_MS,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
server.on('error', (err) => finish(reject, err));
|
|
110
|
+
server.listen(0, '127.0.0.1', () => {
|
|
111
|
+
const { port } = server.address();
|
|
112
|
+
redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
113
|
+
const device = deviceHints();
|
|
114
|
+
const authorizeUrl =
|
|
115
|
+
`${hubUrl}/cli/authorize?` +
|
|
116
|
+
new URLSearchParams({
|
|
117
|
+
response_type: 'code',
|
|
118
|
+
client_id: CLIENT_ID,
|
|
119
|
+
redirect_uri: redirectUri,
|
|
120
|
+
code_challenge: challenge,
|
|
121
|
+
code_challenge_method: 'S256',
|
|
122
|
+
state,
|
|
123
|
+
// Display-only context for the approval screen (not part of PKCE).
|
|
124
|
+
client_version: device.version,
|
|
125
|
+
os: device.os,
|
|
126
|
+
arch: device.arch,
|
|
127
|
+
});
|
|
128
|
+
console.log('Opening your browser to authorize the FluidCAD CLI…');
|
|
129
|
+
console.log(`\n ${authorizeUrl}\n`);
|
|
130
|
+
openBrowser(authorizeUrl);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function registerLoginCommand(program) {
|
|
136
|
+
program
|
|
137
|
+
.command('login')
|
|
138
|
+
.description('Authenticate this machine with the FluidCAD hub')
|
|
139
|
+
.option('--hub <url>', 'Hub base URL (default: $FLUIDCAD_HUB_URL or https://hub.fluidcad.io)')
|
|
140
|
+
.action((opts) => {
|
|
141
|
+
runLogin(opts)
|
|
142
|
+
.then((email) => console.log(`Logged in as ${email ?? 'your account'}`))
|
|
143
|
+
.catch((err) => {
|
|
144
|
+
console.error(err?.message ?? err);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
package/bin/commands/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolve, dirname } from 'path';
|
|
2
|
-
import { fileURLToPath } from 'url';
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
3
3
|
|
|
4
4
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
5
|
const mcpEntry = resolve(__dirname, '..', '..', 'mcp', 'dist', 'server.js');
|
|
@@ -10,7 +10,8 @@ async function runMcp() {
|
|
|
10
10
|
console.log = (...args) => console.error(...args);
|
|
11
11
|
console.info = (...args) => console.error(...args);
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// file:// URL required: Windows ESM loader rejects bare drive paths (D:\...).
|
|
14
|
+
const mod = await import(pathToFileURL(mcpEntry).href);
|
|
14
15
|
if (typeof mod.runStdio !== 'function') {
|
|
15
16
|
console.error('mcp/dist/server.js does not export runStdio.');
|
|
16
17
|
process.exit(1);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { writeFileSync } from 'fs';
|
|
2
|
+
import { resolve, basename } from 'path';
|
|
3
|
+
import { findEntry, readPackageVersion } from '../lib/workspace.js';
|
|
4
|
+
|
|
5
|
+
async function runPack(opts) {
|
|
6
|
+
// The packer lives in server/ because esbuild + jszip belong at that layer;
|
|
7
|
+
// import it lazily so the rest of the CLI doesn't pay the cost.
|
|
8
|
+
const { packModel } = await import('../../server/dist/model-package/pack.js');
|
|
9
|
+
|
|
10
|
+
const workspace = resolve(opts.workspace ?? process.cwd());
|
|
11
|
+
const entry = findEntry(workspace, opts.entry);
|
|
12
|
+
const fluidcadVersion = readPackageVersion();
|
|
13
|
+
|
|
14
|
+
const { manifest, zip } = await packModel({
|
|
15
|
+
entryPath: entry,
|
|
16
|
+
workspacePath: workspace,
|
|
17
|
+
fluidcadVersion,
|
|
18
|
+
name: opts.name,
|
|
19
|
+
description: opts.description,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const outPath = opts.out
|
|
23
|
+
? resolve(opts.out)
|
|
24
|
+
: resolve(workspace, basename(entry).replace(/\.fluid\.js$/i, '') + '.fluidpkg');
|
|
25
|
+
writeFileSync(outPath, zip);
|
|
26
|
+
const fileCount = manifest.files?.length ?? 0;
|
|
27
|
+
const assetCount = manifest.assets.length;
|
|
28
|
+
console.log(
|
|
29
|
+
`Wrote ${outPath} (${zip.length} bytes, ${fileCount} file${fileCount === 1 ? '' : 's'}, ` +
|
|
30
|
+
`${assetCount} asset${assetCount === 1 ? '' : 's'})`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function registerPackCommand(program) {
|
|
35
|
+
program
|
|
36
|
+
.command('pack')
|
|
37
|
+
.description('Package a .fluid.js model into a shareable .fluidpkg archive')
|
|
38
|
+
.option('-w, --workspace <path>', 'Workspace directory (defaults to cwd)')
|
|
39
|
+
.option('-e, --entry <file>', 'Entry .fluid.js file (auto-detected if only one exists)')
|
|
40
|
+
.option('-o, --out <path>', 'Output path (defaults to <entry-basename>.fluidpkg in the workspace)')
|
|
41
|
+
.option('-n, --name <name>', 'Package name (defaults to the entry file basename)')
|
|
42
|
+
.option('-d, --description <text>', 'Optional human description')
|
|
43
|
+
.action((opts) => {
|
|
44
|
+
runPack(opts).catch((err) => {
|
|
45
|
+
console.error(err?.message ?? err);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { resolve } from 'path';
|
|
2
|
+
import { getHubUrl, readCredentials } from '../lib/config.js';
|
|
3
|
+
import { HubClient } from '../lib/api-client.js';
|
|
4
|
+
import { findEntry, readPackageVersion, readWorkspacePackage } from '../lib/workspace.js';
|
|
5
|
+
import { readModelIdentity, writeModelConfig } from '../lib/model-config.js';
|
|
6
|
+
import { isInteractive, select } from '../lib/prompt.js';
|
|
7
|
+
import { openBrowser } from '../lib/browser.js';
|
|
8
|
+
|
|
9
|
+
async function runPublish(opts) {
|
|
10
|
+
const creds = readCredentials();
|
|
11
|
+
if (!creds) {
|
|
12
|
+
throw new Error('Not logged in. Run `fluidcad login` first.');
|
|
13
|
+
}
|
|
14
|
+
const hubUrl = getHubUrl(opts.hub || creds.hubUrl);
|
|
15
|
+
|
|
16
|
+
const workspace = resolve(opts.workspace ?? process.cwd());
|
|
17
|
+
const entry = findEntry(workspace, opts.entry);
|
|
18
|
+
|
|
19
|
+
// Prefills from the model's own package.json + the stable identity from
|
|
20
|
+
// fluidcad.json (null on the first publish → the hub mints one).
|
|
21
|
+
const pkg = readWorkspacePackage(workspace);
|
|
22
|
+
const name = opts.name ?? pkg.name;
|
|
23
|
+
const description = opts.description ?? pkg.description;
|
|
24
|
+
const { modelId: priorModelId, name: priorName } = readModelIdentity(workspace);
|
|
25
|
+
|
|
26
|
+
// Surface who we are and where this is going *before* any bytes leave the
|
|
27
|
+
// machine, so a wrong account or hub is caught before the upload starts.
|
|
28
|
+
// (The model's own page URL is minted by the hub and printed afterwards.)
|
|
29
|
+
console.log('Publishing to the FluidCAD hub:');
|
|
30
|
+
console.log(` account: ${creds.email || '(unknown account)'}`);
|
|
31
|
+
console.log(` url: ${hubUrl}`);
|
|
32
|
+
console.log('');
|
|
33
|
+
|
|
34
|
+
// Decide new-model vs new-version BEFORE the heavy build, so the user makes
|
|
35
|
+
// the call (and we do any model-list lookup) without first waiting ~110ms for
|
|
36
|
+
// the engine to load. A null target ⇒ the hub mints a fresh model.
|
|
37
|
+
const targetModelId = await resolveTargetModel({
|
|
38
|
+
opts,
|
|
39
|
+
hubUrl,
|
|
40
|
+
token: creds.token,
|
|
41
|
+
priorModelId,
|
|
42
|
+
priorName,
|
|
43
|
+
});
|
|
44
|
+
console.log(
|
|
45
|
+
targetModelId
|
|
46
|
+
? `Publishing a new version${priorName ? ` of ${priorName}` : ''}.\n`
|
|
47
|
+
: 'Publishing as a new model.\n',
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Render once to capture the full param schema for the manifest. This also
|
|
51
|
+
// acts as a build gate — a compile/runtime error fails the publish here,
|
|
52
|
+
// before anything is uploaded.
|
|
53
|
+
//
|
|
54
|
+
// Dynamic import is deliberate (and necessary): this pulls in the whole
|
|
55
|
+
// engine — Vite + OC wasm, ~40MB / ~110ms. bin/fluidcad.js eagerly loads
|
|
56
|
+
// every command module at startup, so a top-level import here would make
|
|
57
|
+
// `init`, `serve`, `login`, `--help` etc. pay that cost too. Loading it only
|
|
58
|
+
// when `publish` actually runs is the justified exception to "no inline
|
|
59
|
+
// imports".
|
|
60
|
+
console.log('Building model…');
|
|
61
|
+
const { captureParamDefinitions } = await import(
|
|
62
|
+
'../../server/dist/model-package/capture-params.js'
|
|
63
|
+
);
|
|
64
|
+
let paramDefinitions;
|
|
65
|
+
try {
|
|
66
|
+
paramDefinitions = await captureParamDefinitions(entry, workspace);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw new Error(`Model failed to build — fix the error and retry:\n${err?.message ?? err}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Pack the whole workspace (Pack v2), embedding the captured params. Lazy
|
|
72
|
+
// import for the same reason (keeps esbuild/jszip off the startup path of
|
|
73
|
+
// non-publish commands), consistent with how `pack` loads it.
|
|
74
|
+
const { packModel } = await import('../../server/dist/model-package/pack.js');
|
|
75
|
+
const { manifest, zip } = await packModel({
|
|
76
|
+
entryPath: entry,
|
|
77
|
+
workspacePath: workspace,
|
|
78
|
+
fluidcadVersion: readPackageVersion(),
|
|
79
|
+
name,
|
|
80
|
+
description,
|
|
81
|
+
paramDefinitions,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Show exactly what's going up so stray files/secrets get caught before they
|
|
85
|
+
// leave the machine (the .gitignore guard plus an always-exclude list).
|
|
86
|
+
const files = manifest.files ?? [];
|
|
87
|
+
console.log(`\nUploading ${files.length} file${files.length === 1 ? '' : 's'} (${(zip.length / 1024).toFixed(1)} KB):`);
|
|
88
|
+
for (const f of files) {
|
|
89
|
+
console.log(` ${f}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const form = new FormData();
|
|
93
|
+
form.append('fluidpkg', new Blob([zip], { type: 'application/zip' }), 'model.fluidpkg');
|
|
94
|
+
if (targetModelId) {
|
|
95
|
+
form.append('modelId', targetModelId);
|
|
96
|
+
}
|
|
97
|
+
if (name) {
|
|
98
|
+
form.append('name', name);
|
|
99
|
+
}
|
|
100
|
+
if (opts.visibility) {
|
|
101
|
+
form.append('visibility', opts.visibility);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { status, body } = await new HubClient(hubUrl, creds.token).postForm('/api/publish', form);
|
|
105
|
+
if (status === 401) {
|
|
106
|
+
throw new Error('Your session has expired. Run `fluidcad login` again.');
|
|
107
|
+
}
|
|
108
|
+
if (status === 403) {
|
|
109
|
+
throw new Error(body.error || 'That model belongs to another account.');
|
|
110
|
+
}
|
|
111
|
+
if (status === 422) {
|
|
112
|
+
throw new Error(body.error || 'That FluidCAD version is not hosted yet.');
|
|
113
|
+
}
|
|
114
|
+
if (status !== 200 && status !== 201) {
|
|
115
|
+
throw new Error(body.error || `Publish failed (HTTP ${status})`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Persist the hub-authoritative id (and the name we used) whenever it changed
|
|
119
|
+
// or the workspace had no config — covers the first publish, a deliberate new
|
|
120
|
+
// model, and re-attaching a deleted fluidcad.json (the user picked an existing
|
|
121
|
+
// model from the list). An owned-match with an unchanged name is a no-op.
|
|
122
|
+
if (body.modelId && (body.modelId !== priorModelId || (name && name !== priorName))) {
|
|
123
|
+
writeModelConfig(workspace, { modelId: body.modelId, name });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log('');
|
|
127
|
+
if (body.isNewVersion) {
|
|
128
|
+
console.log(`A new version (v${body.version}) will be published — add details in your browser.`);
|
|
129
|
+
} else {
|
|
130
|
+
console.log('Created model — finish setup in your browser.');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (body.formUrl) {
|
|
134
|
+
const formUrl = body.formUrl.startsWith('http') ? body.formUrl : hubUrl + body.formUrl;
|
|
135
|
+
console.log(`\n ${formUrl}\n`);
|
|
136
|
+
await openBrowser(formUrl);
|
|
137
|
+
} else if (body.shareUrl) {
|
|
138
|
+
// Fallback for a pre-v2 hub that deployed synchronously and has no form.
|
|
139
|
+
console.log(`\n ${body.shareUrl}\n`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Decide which model this publish targets: an existing model id (→ a new
|
|
145
|
+
* version) or null (→ the hub mints a new model). Honors --new-model /
|
|
146
|
+
* --new-version; otherwise asks when interactive; and with no TTY falls back to
|
|
147
|
+
* today's behavior (a saved fluidcad.json id ⇒ a version, else a new model).
|
|
148
|
+
*/
|
|
149
|
+
async function resolveTargetModel({ opts, hubUrl, token, priorModelId, priorName }) {
|
|
150
|
+
if (opts.newModel && opts.newVersion) {
|
|
151
|
+
throw new Error('Pass only one of --new-model / --new-version.');
|
|
152
|
+
}
|
|
153
|
+
if (opts.newModel) return null;
|
|
154
|
+
if (opts.newVersion) {
|
|
155
|
+
if (priorModelId) return priorModelId;
|
|
156
|
+
if (isInteractive()) return pickExistingModel(hubUrl, token);
|
|
157
|
+
throw new Error(
|
|
158
|
+
'No fluidcad.json here, so there is no model to version. Drop --new-version to ' +
|
|
159
|
+
'publish a new model, or run interactively to pick an existing one.',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// No explicit flag.
|
|
164
|
+
if (!isInteractive()) {
|
|
165
|
+
// Non-interactive (CI, piped input): keep the historical default.
|
|
166
|
+
return priorModelId;
|
|
167
|
+
}
|
|
168
|
+
if (priorModelId) {
|
|
169
|
+
const label = priorName ? `${priorName} (${priorModelId})` : priorModelId;
|
|
170
|
+
return select('How should this publish go up?', [
|
|
171
|
+
{ label: `Publish a new version of ${label}`, value: priorModelId },
|
|
172
|
+
{ label: 'Publish as a new model', value: null },
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
const choice = await select('How should this publish go up?', [
|
|
176
|
+
{ label: 'Publish a new version of an existing model', value: '__existing__' },
|
|
177
|
+
{ label: 'Publish as a new model', value: null },
|
|
178
|
+
]);
|
|
179
|
+
return choice === '__existing__' ? pickExistingModel(hubUrl, token) : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Fetch the user's own models from the hub and let them pick which one this is a
|
|
184
|
+
* new version of. Returns the chosen model id, or null when they have none yet
|
|
185
|
+
* (the caller then mints a new model).
|
|
186
|
+
*/
|
|
187
|
+
async function pickExistingModel(hubUrl, token) {
|
|
188
|
+
const { status, body } = await new HubClient(hubUrl, token).getJson('/api/cli/models');
|
|
189
|
+
if (status === 401) {
|
|
190
|
+
throw new Error('Your session has expired. Run `fluidcad login` again.');
|
|
191
|
+
}
|
|
192
|
+
if (status !== 200) {
|
|
193
|
+
throw new Error(body.error || `Could not list your models (HTTP ${status})`);
|
|
194
|
+
}
|
|
195
|
+
const models = Array.isArray(body.models) ? body.models : [];
|
|
196
|
+
if (models.length === 0) {
|
|
197
|
+
console.log('\nYou have no models on the hub yet — publishing this as a new model.\n');
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
return select(
|
|
201
|
+
'Which model is this a new version of?',
|
|
202
|
+
models.map((m) => ({
|
|
203
|
+
label: `${m.name} · ${m.latestVersion ? 'v' + m.latestVersion : 'no versions yet'}`,
|
|
204
|
+
value: m.id,
|
|
205
|
+
})),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function registerPublishCommand(program) {
|
|
210
|
+
program
|
|
211
|
+
.command('publish')
|
|
212
|
+
.description('Pack the current model and publish it to the FluidCAD hub')
|
|
213
|
+
.option('-w, --workspace <path>', 'Workspace directory (defaults to cwd)')
|
|
214
|
+
.option('-e, --entry <file>', 'Entry .fluid.js file (auto-detected if only one exists)')
|
|
215
|
+
.option('-n, --name <name>', 'Model name (defaults to the package name)')
|
|
216
|
+
.option('-d, --description <text>', 'Optional human description')
|
|
217
|
+
.option('--new-model', 'Publish as a new model, ignoring any saved model id')
|
|
218
|
+
.option('--new-version', 'Publish a new version of the saved (or chosen) model')
|
|
219
|
+
.option('--visibility <visibility>', 'public | unlisted | private (default: unlisted)')
|
|
220
|
+
.option('--hub <url>', 'Hub base URL (default: the hub you logged into)')
|
|
221
|
+
.action((opts) => {
|
|
222
|
+
runPublish(opts)
|
|
223
|
+
// The render spins up engine + Vite handles; exit explicitly so a
|
|
224
|
+
// successful publish doesn't hang waiting for the loop to drain.
|
|
225
|
+
.then(() => process.exit(0))
|
|
226
|
+
.catch((err) => {
|
|
227
|
+
console.error(err?.message ?? err);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
package/bin/fluidcad.js
CHANGED
|
@@ -7,6 +7,9 @@ import { fileURLToPath } from 'url';
|
|
|
7
7
|
import { registerInitCommand } from './commands/init.js';
|
|
8
8
|
import { registerServeCommand } from './commands/serve.js';
|
|
9
9
|
import { registerMcpCommand } from './commands/mcp.js';
|
|
10
|
+
import { registerPackCommand } from './commands/pack.js';
|
|
11
|
+
import { registerLoginCommand } from './commands/login.js';
|
|
12
|
+
import { registerPublishCommand } from './commands/publish.js';
|
|
10
13
|
|
|
11
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
15
|
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -19,5 +22,8 @@ const program = new Command()
|
|
|
19
22
|
registerInitCommand(program);
|
|
20
23
|
registerServeCommand(program);
|
|
21
24
|
registerMcpCommand(program);
|
|
25
|
+
registerPackCommand(program);
|
|
26
|
+
registerLoginCommand(program);
|
|
27
|
+
registerPublishCommand(program);
|
|
22
28
|
|
|
23
29
|
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getHubUrl } from './config.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tiny client for the hub API. The CLI commands are pure API clients — open a
|
|
5
|
+
* browser, store a token, POST a package — so this just wraps `fetch` with the
|
|
6
|
+
* bearer header and a uniform `{ status, body }` result.
|
|
7
|
+
*/
|
|
8
|
+
export class HubClient {
|
|
9
|
+
constructor(hubUrl, token) {
|
|
10
|
+
this.base = getHubUrl(hubUrl);
|
|
11
|
+
this.token = token;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#authHeaders(extra = {}) {
|
|
15
|
+
return { ...(this.token ? { authorization: `Bearer ${this.token}` } : {}), ...extra };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async #result(res) {
|
|
19
|
+
const body = await res.json().catch(() => ({}));
|
|
20
|
+
return { status: res.status, body };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async getJson(path) {
|
|
24
|
+
const res = await fetch(this.base + path, {
|
|
25
|
+
method: 'GET',
|
|
26
|
+
headers: this.#authHeaders(),
|
|
27
|
+
});
|
|
28
|
+
return this.#result(res);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async postJson(path, body) {
|
|
32
|
+
const res = await fetch(this.base + path, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: this.#authHeaders({ 'content-type': 'application/json' }),
|
|
35
|
+
body: JSON.stringify(body),
|
|
36
|
+
});
|
|
37
|
+
return this.#result(res);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async postForm(path, form) {
|
|
41
|
+
const res = await fetch(this.base + path, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: this.#authHeaders(),
|
|
44
|
+
body: form,
|
|
45
|
+
});
|
|
46
|
+
return this.#result(res);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import open from 'open';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Best-effort browser open, shared by `login` and `publish`. The caller always
|
|
5
|
+
* prints the URL too, so a failure here (headless box, SSH, no DISPLAY) is
|
|
6
|
+
* non-fatal — the user can open it manually.
|
|
7
|
+
*/
|
|
8
|
+
export async function openBrowser(url) {
|
|
9
|
+
try {
|
|
10
|
+
const child = await open(url);
|
|
11
|
+
// Detach so a one-shot CLI process isn't held open by the browser handle.
|
|
12
|
+
child.unref?.();
|
|
13
|
+
} catch {
|
|
14
|
+
/* ignore — the URL is printed regardless */
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from 'fs';
|
|
4
|
+
|
|
5
|
+
// Where the CLI keeps its hub credentials. XDG-ish; one file, mode 0600.
|
|
6
|
+
const CONFIG_DIR = join(homedir(), '.config', 'fluidcad');
|
|
7
|
+
export const CREDENTIALS_PATH = join(CONFIG_DIR, 'credentials.json');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_HUB_URL = 'https://hub.fluidcad.io';
|
|
10
|
+
|
|
11
|
+
/** Resolve the hub base URL: explicit override → $FLUIDCAD_HUB_URL → default. */
|
|
12
|
+
export function getHubUrl(override) {
|
|
13
|
+
const url = override || process.env.FLUIDCAD_HUB_URL || DEFAULT_HUB_URL;
|
|
14
|
+
return url.replace(/\/+$/, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Read saved credentials, or null if not logged in / unreadable. */
|
|
18
|
+
export function readCredentials() {
|
|
19
|
+
try {
|
|
20
|
+
const creds = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'));
|
|
21
|
+
return creds && creds.token ? creds : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Persist credentials at mode 0600 (re-chmod in case the file pre-existed). */
|
|
28
|
+
export function writeCredentials(creds) {
|
|
29
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
30
|
+
writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
|
|
31
|
+
chmodSync(CREDENTIALS_PATH, 0o600);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Forget saved credentials (used by `logout`). */
|
|
35
|
+
export function clearCredentials() {
|
|
36
|
+
if (existsSync(CREDENTIALS_PATH)) {
|
|
37
|
+
writeFileSync(CREDENTIALS_PATH, '{}\n', { mode: 0o600 });
|
|
38
|
+
}
|
|
39
|
+
}
|