fluidcad 0.0.34 → 0.0.35
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 +120 -0
- package/bin/commands/pack.js +49 -0
- package/bin/commands/publish.js +136 -0
- package/bin/fluidcad.js +6 -0
- package/bin/lib/api-client.js +40 -0
- package/bin/lib/browser.js +16 -0
- package/bin/lib/config.js +39 -0
- package/bin/lib/model-config.js +38 -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 +68 -1
- package/server/dist/fluidcad-server.js +224 -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 +229 -0
- package/server/dist/model-package/types.d.ts +78 -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,120 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { randomBytes, createHash } from 'crypto';
|
|
3
|
+
import { getHubUrl, writeCredentials } from '../lib/config.js';
|
|
4
|
+
import { HubClient } from '../lib/api-client.js';
|
|
5
|
+
import { openBrowser } from '../lib/browser.js';
|
|
6
|
+
|
|
7
|
+
const CLIENT_ID = 'fluidcad-cli';
|
|
8
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
function base64url(buf) {
|
|
11
|
+
return buf.toString('base64url');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Loopback + PKCE login (RFC 8252): spin up a one-shot 127.0.0.1 server, send
|
|
16
|
+
* the browser to the hub's /cli/authorize, receive the code on /callback,
|
|
17
|
+
* exchange it (with the PKCE verifier) for a token, and save it.
|
|
18
|
+
*/
|
|
19
|
+
function runLogin(opts) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const hubUrl = getHubUrl(opts.hub);
|
|
22
|
+
const verifier = base64url(randomBytes(32));
|
|
23
|
+
const challenge = base64url(createHash('sha256').update(verifier).digest());
|
|
24
|
+
const state = base64url(randomBytes(16));
|
|
25
|
+
let redirectUri;
|
|
26
|
+
let settled = false;
|
|
27
|
+
|
|
28
|
+
const finish = (fn, arg) => {
|
|
29
|
+
if (settled) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
settled = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
server.close();
|
|
35
|
+
fn(arg);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const server = http.createServer((req, res) => {
|
|
39
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
40
|
+
if (url.pathname !== '/callback') {
|
|
41
|
+
res.writeHead(404);
|
|
42
|
+
res.end();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
46
|
+
res.end(
|
|
47
|
+
'<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;text-align:center;padding-top:3rem">' +
|
|
48
|
+
'<h2>FluidCAD CLI</h2><p>You can close this tab and return to your terminal.</p>',
|
|
49
|
+
);
|
|
50
|
+
const error = url.searchParams.get('error');
|
|
51
|
+
const code = url.searchParams.get('code');
|
|
52
|
+
const returnedState = url.searchParams.get('state');
|
|
53
|
+
|
|
54
|
+
(async () => {
|
|
55
|
+
if (error) {
|
|
56
|
+
throw new Error(`authorization ${error}`);
|
|
57
|
+
}
|
|
58
|
+
if (returnedState !== state) {
|
|
59
|
+
throw new Error('state mismatch — aborting (possible CSRF)');
|
|
60
|
+
}
|
|
61
|
+
if (!code) {
|
|
62
|
+
throw new Error('no authorization code in callback');
|
|
63
|
+
}
|
|
64
|
+
const { status, body } = await new HubClient(hubUrl).postJson('/api/cli/token', {
|
|
65
|
+
client_id: CLIENT_ID,
|
|
66
|
+
code,
|
|
67
|
+
code_verifier: verifier,
|
|
68
|
+
redirect_uri: redirectUri,
|
|
69
|
+
});
|
|
70
|
+
if (status !== 200 || !body.access_token) {
|
|
71
|
+
throw new Error(`token exchange failed: ${body.error_description || body.error || `HTTP ${status}`}`);
|
|
72
|
+
}
|
|
73
|
+
writeCredentials({ token: body.access_token, email: body.user?.email ?? null, hubUrl });
|
|
74
|
+
return body.user?.email ?? null;
|
|
75
|
+
})().then(
|
|
76
|
+
(email) => finish(resolve, email),
|
|
77
|
+
(err) => finish(reject, err),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const timer = setTimeout(
|
|
82
|
+
() => finish(reject, new Error('login timed out — no response within 5 minutes')),
|
|
83
|
+
LOGIN_TIMEOUT_MS,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
server.on('error', (err) => finish(reject, err));
|
|
87
|
+
server.listen(0, '127.0.0.1', () => {
|
|
88
|
+
const { port } = server.address();
|
|
89
|
+
redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
90
|
+
const authorizeUrl =
|
|
91
|
+
`${hubUrl}/cli/authorize?` +
|
|
92
|
+
new URLSearchParams({
|
|
93
|
+
response_type: 'code',
|
|
94
|
+
client_id: CLIENT_ID,
|
|
95
|
+
redirect_uri: redirectUri,
|
|
96
|
+
code_challenge: challenge,
|
|
97
|
+
code_challenge_method: 'S256',
|
|
98
|
+
state,
|
|
99
|
+
});
|
|
100
|
+
console.log('Opening your browser to authorize the FluidCAD CLI…');
|
|
101
|
+
console.log(`\n ${authorizeUrl}\n`);
|
|
102
|
+
openBrowser(authorizeUrl);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function registerLoginCommand(program) {
|
|
108
|
+
program
|
|
109
|
+
.command('login')
|
|
110
|
+
.description('Authenticate this machine with the FluidCAD hub')
|
|
111
|
+
.option('--hub <url>', 'Hub base URL (default: $FLUIDCAD_HUB_URL or https://hub.fluidcad.io)')
|
|
112
|
+
.action((opts) => {
|
|
113
|
+
runLogin(opts)
|
|
114
|
+
.then((email) => console.log(`Logged in as ${email ?? 'your account'}`))
|
|
115
|
+
.catch((err) => {
|
|
116
|
+
console.error(err?.message ?? err);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -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,136 @@
|
|
|
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 { readModelId, writeModelId } from '../lib/model-config.js';
|
|
6
|
+
import { openBrowser } from '../lib/browser.js';
|
|
7
|
+
|
|
8
|
+
async function runPublish(opts) {
|
|
9
|
+
const creds = readCredentials();
|
|
10
|
+
if (!creds) {
|
|
11
|
+
throw new Error('Not logged in. Run `fluidcad login` first.');
|
|
12
|
+
}
|
|
13
|
+
const hubUrl = getHubUrl(opts.hub || creds.hubUrl);
|
|
14
|
+
|
|
15
|
+
const workspace = resolve(opts.workspace ?? process.cwd());
|
|
16
|
+
const entry = findEntry(workspace, opts.entry);
|
|
17
|
+
|
|
18
|
+
// Prefills from the model's own package.json + the stable identity from
|
|
19
|
+
// fluidcad.json (null on the first publish → the hub mints one).
|
|
20
|
+
const pkg = readWorkspacePackage(workspace);
|
|
21
|
+
const name = opts.name ?? pkg.name;
|
|
22
|
+
const description = opts.description ?? pkg.description;
|
|
23
|
+
const modelId = readModelId(workspace);
|
|
24
|
+
|
|
25
|
+
// Render once to capture the full param schema for the manifest. This also
|
|
26
|
+
// acts as a build gate — a compile/runtime error fails the publish here,
|
|
27
|
+
// before anything is uploaded.
|
|
28
|
+
//
|
|
29
|
+
// Dynamic import is deliberate (and necessary): this pulls in the whole
|
|
30
|
+
// engine — Vite + OC wasm, ~40MB / ~110ms. bin/fluidcad.js eagerly loads
|
|
31
|
+
// every command module at startup, so a top-level import here would make
|
|
32
|
+
// `init`, `serve`, `login`, `--help` etc. pay that cost too. Loading it only
|
|
33
|
+
// when `publish` actually runs is the justified exception to "no inline
|
|
34
|
+
// imports".
|
|
35
|
+
console.log('Building model…');
|
|
36
|
+
const { captureParamDefinitions } = await import(
|
|
37
|
+
'../../server/dist/model-package/capture-params.js'
|
|
38
|
+
);
|
|
39
|
+
let paramDefinitions;
|
|
40
|
+
try {
|
|
41
|
+
paramDefinitions = await captureParamDefinitions(entry, workspace);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new Error(`Model failed to build — fix the error and retry:\n${err?.message ?? err}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Pack the whole workspace (Pack v2), embedding the captured params. Lazy
|
|
47
|
+
// import for the same reason (keeps esbuild/jszip off the startup path of
|
|
48
|
+
// non-publish commands), consistent with how `pack` loads it.
|
|
49
|
+
const { packModel } = await import('../../server/dist/model-package/pack.js');
|
|
50
|
+
const { manifest, zip } = await packModel({
|
|
51
|
+
entryPath: entry,
|
|
52
|
+
workspacePath: workspace,
|
|
53
|
+
fluidcadVersion: readPackageVersion(),
|
|
54
|
+
name,
|
|
55
|
+
description,
|
|
56
|
+
paramDefinitions,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Show exactly what's going up so stray files/secrets get caught before they
|
|
60
|
+
// leave the machine (the .gitignore guard plus an always-exclude list).
|
|
61
|
+
const files = manifest.files ?? [];
|
|
62
|
+
console.log(`\nUploading ${files.length} file${files.length === 1 ? '' : 's'} (${(zip.length / 1024).toFixed(1)} KB):`);
|
|
63
|
+
for (const f of files) {
|
|
64
|
+
console.log(` ${f}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const form = new FormData();
|
|
68
|
+
form.append('fluidpkg', new Blob([zip], { type: 'application/zip' }), 'model.fluidpkg');
|
|
69
|
+
if (modelId) {
|
|
70
|
+
form.append('modelId', modelId);
|
|
71
|
+
}
|
|
72
|
+
if (name) {
|
|
73
|
+
form.append('name', name);
|
|
74
|
+
}
|
|
75
|
+
if (opts.visibility) {
|
|
76
|
+
form.append('visibility', opts.visibility);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { status, body } = await new HubClient(hubUrl, creds.token).postForm('/api/publish', form);
|
|
80
|
+
if (status === 401) {
|
|
81
|
+
throw new Error('Your session has expired. Run `fluidcad login` again.');
|
|
82
|
+
}
|
|
83
|
+
if (status === 403) {
|
|
84
|
+
throw new Error(body.error || 'That model belongs to another account.');
|
|
85
|
+
}
|
|
86
|
+
if (status === 422) {
|
|
87
|
+
throw new Error(body.error || 'That FluidCAD version is not hosted yet.');
|
|
88
|
+
}
|
|
89
|
+
if (status !== 200 && status !== 201) {
|
|
90
|
+
throw new Error(body.error || `Publish failed (HTTP ${status})`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// First publish for this workspace → persist the hub-minted id so the next
|
|
94
|
+
// publish lands as a new version of the same model.
|
|
95
|
+
if (!modelId && body.modelId) {
|
|
96
|
+
writeModelId(workspace, body.modelId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log('');
|
|
100
|
+
if (body.isNewVersion) {
|
|
101
|
+
console.log(`A new version (v${body.version}) will be published — add details in your browser.`);
|
|
102
|
+
} else {
|
|
103
|
+
console.log('Created model — finish setup in your browser.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (body.formUrl) {
|
|
107
|
+
const formUrl = body.formUrl.startsWith('http') ? body.formUrl : hubUrl + body.formUrl;
|
|
108
|
+
console.log(`\n ${formUrl}\n`);
|
|
109
|
+
await openBrowser(formUrl);
|
|
110
|
+
} else if (body.shareUrl) {
|
|
111
|
+
// Fallback for a pre-v2 hub that deployed synchronously and has no form.
|
|
112
|
+
console.log(`\n ${body.shareUrl}\n`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function registerPublishCommand(program) {
|
|
117
|
+
program
|
|
118
|
+
.command('publish')
|
|
119
|
+
.description('Pack the current model and publish it to the FluidCAD hub')
|
|
120
|
+
.option('-w, --workspace <path>', 'Workspace directory (defaults to cwd)')
|
|
121
|
+
.option('-e, --entry <file>', 'Entry .fluid.js file (auto-detected if only one exists)')
|
|
122
|
+
.option('-n, --name <name>', 'Model name (defaults to the package name)')
|
|
123
|
+
.option('-d, --description <text>', 'Optional human description')
|
|
124
|
+
.option('--visibility <visibility>', 'public | unlisted | private (default: unlisted)')
|
|
125
|
+
.option('--hub <url>', 'Hub base URL (default: the hub you logged into)')
|
|
126
|
+
.action((opts) => {
|
|
127
|
+
runPublish(opts)
|
|
128
|
+
// The render spins up engine + Vite handles; exit explicitly so a
|
|
129
|
+
// successful publish doesn't hang waiting for the loop to drain.
|
|
130
|
+
.then(() => process.exit(0))
|
|
131
|
+
.catch((err) => {
|
|
132
|
+
console.error(err?.message ?? err);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
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,40 @@
|
|
|
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 postJson(path, body) {
|
|
24
|
+
const res = await fetch(this.base + path, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: this.#authHeaders({ 'content-type': 'application/json' }),
|
|
27
|
+
body: JSON.stringify(body),
|
|
28
|
+
});
|
|
29
|
+
return this.#result(res);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async postForm(path, form) {
|
|
33
|
+
const res = await fetch(this.base + path, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: this.#authHeaders(),
|
|
36
|
+
body: form,
|
|
37
|
+
});
|
|
38
|
+
return this.#result(res);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
// `fluidcad.json` at the workspace root holds the stable, hub-minted model
|
|
5
|
+
// identity (and room to grow: default visibility, name, …). It's meant to be
|
|
6
|
+
// committed — like `fly.toml`, it ties a workspace to the model it publishes
|
|
7
|
+
// to, so re-publishing creates a new VERSION rather than an unrelated model.
|
|
8
|
+
const FILENAME = 'fluidcad.json';
|
|
9
|
+
|
|
10
|
+
export function modelConfigPath(workspace) {
|
|
11
|
+
return join(workspace, FILENAME);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Read `fluidcad.json`, or an empty object if missing/unreadable. */
|
|
15
|
+
export function readModelConfig(workspace) {
|
|
16
|
+
try {
|
|
17
|
+
const cfg = JSON.parse(readFileSync(modelConfigPath(workspace), 'utf8'));
|
|
18
|
+
return cfg && typeof cfg === 'object' ? cfg : {};
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The persisted hub model id for this workspace, or null on first publish. */
|
|
25
|
+
export function readModelId(workspace) {
|
|
26
|
+
const cfg = readModelConfig(workspace);
|
|
27
|
+
return typeof cfg.modelId === 'string' && cfg.modelId ? cfg.modelId : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Persist the hub-minted model id, preserving any other fields already in
|
|
32
|
+
* `fluidcad.json`. Called after the first publish writes back the new id.
|
|
33
|
+
*/
|
|
34
|
+
export function writeModelId(workspace, modelId) {
|
|
35
|
+
const cfg = readModelConfig(workspace);
|
|
36
|
+
cfg.modelId = modelId;
|
|
37
|
+
writeFileSync(modelConfigPath(workspace), JSON.stringify(cfg, null, 2) + '\n');
|
|
38
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
2
|
+
import { dirname, join, resolve } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
// Shared by `pack` and `publish`: both resolve the entry `.fluid.js` the same
|
|
8
|
+
// way and stamp the same fluidcad version onto the package.
|
|
9
|
+
|
|
10
|
+
/** The installed fluidcad version (from the package's own package.json). */
|
|
11
|
+
export function readPackageVersion() {
|
|
12
|
+
try {
|
|
13
|
+
const pkgPath = resolve(__dirname, '..', '..', 'package.json');
|
|
14
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? '0.0.0';
|
|
15
|
+
} catch {
|
|
16
|
+
return '0.0.0';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read the MODEL workspace's own `package.json` for publish prefills: name and
|
|
22
|
+
* description (→ short description). Distinct from `readPackageVersion`, which
|
|
23
|
+
* reads the fluidcad engine's version. Missing/unreadable fields come back
|
|
24
|
+
* undefined. (The published version number is hub-assigned, not read here.)
|
|
25
|
+
*/
|
|
26
|
+
export function readWorkspacePackage(workspace) {
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(readFileSync(join(workspace, 'package.json'), 'utf8'));
|
|
29
|
+
return {
|
|
30
|
+
name: typeof pkg.name === 'string' ? pkg.name : undefined,
|
|
31
|
+
description: typeof pkg.description === 'string' ? pkg.description : undefined,
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Resolve the entry `.fluid.js`: the override if given, else the sole one. */
|
|
39
|
+
export function findEntry(workspace, override) {
|
|
40
|
+
if (override) {
|
|
41
|
+
const abs = resolve(workspace, override);
|
|
42
|
+
if (!existsSync(abs)) {
|
|
43
|
+
throw new Error(`Entry file not found: ${abs}`);
|
|
44
|
+
}
|
|
45
|
+
return abs;
|
|
46
|
+
}
|
|
47
|
+
const candidates = readdirSync(workspace).filter((f) => f.endsWith('.fluid.js'));
|
|
48
|
+
if (candidates.length === 0) {
|
|
49
|
+
throw new Error('No .fluid.js files found in the workspace. Pass --entry to specify one.');
|
|
50
|
+
}
|
|
51
|
+
if (candidates.length > 1) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Multiple .fluid.js files found: ${candidates.join(', ')}. Pass --entry to choose one.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return resolve(workspace, candidates[0]);
|
|
57
|
+
}
|
|
@@ -3,6 +3,7 @@ import { Solid } from "./solid.js";
|
|
|
3
3
|
import { Wire } from "./wire.js";
|
|
4
4
|
import { Face } from "./face.js";
|
|
5
5
|
import { Edge } from "./edge.js";
|
|
6
|
+
import { Vertex } from "./vertex.js";
|
|
6
7
|
export declare class ShapeFactory {
|
|
7
|
-
static fromShape(shape: TopoDS_Shape): Edge | Wire | Solid | Face;
|
|
8
|
+
static fromShape(shape: TopoDS_Shape): Vertex | Edge | Wire | Solid | Face;
|
|
8
9
|
}
|
|
@@ -3,6 +3,7 @@ import { Solid } from "./solid.js";
|
|
|
3
3
|
import { Wire } from "./wire.js";
|
|
4
4
|
import { Face } from "./face.js";
|
|
5
5
|
import { Edge } from "./edge.js";
|
|
6
|
+
import { Vertex } from "./vertex.js";
|
|
6
7
|
export class ShapeFactory {
|
|
7
8
|
static fromShape(shape) {
|
|
8
9
|
if (Explorer.isSolid(shape)) {
|
|
@@ -17,6 +18,9 @@ export class ShapeFactory {
|
|
|
17
18
|
if (Explorer.isEdge(shape)) {
|
|
18
19
|
return Edge.fromTopoDSEdge(Explorer.toEdge(shape));
|
|
19
20
|
}
|
|
21
|
+
if (Explorer.isVertex(shape)) {
|
|
22
|
+
return Vertex.fromTopoDSVertex(Explorer.toVertex(shape));
|
|
23
|
+
}
|
|
20
24
|
if (Explorer.isCompound(shape) || Explorer.isCompoundSolid(shape)) {
|
|
21
25
|
const solids = Explorer.findShapes(shape, Explorer.getOcShapeType("solid"));
|
|
22
26
|
if (solids.length === 1) {
|
|
@@ -3,14 +3,15 @@ import { Matrix4 } from "../math/matrix4.js";
|
|
|
3
3
|
import type { AxisLike } from "../math/axis.js";
|
|
4
4
|
import type { PlaneLike } from "../math/plane.js";
|
|
5
5
|
import type { PointLike } from "../math/point.js";
|
|
6
|
+
import { type NumberParam } from "../core/param.js";
|
|
6
7
|
export declare abstract class TransformablePrimitive extends SceneObject {
|
|
7
8
|
transform(matrix: Matrix4): this;
|
|
8
|
-
translate(x:
|
|
9
|
-
translate(x:
|
|
10
|
-
translate(x:
|
|
9
|
+
translate(x: NumberParam): this;
|
|
10
|
+
translate(x: NumberParam, y: NumberParam): this;
|
|
11
|
+
translate(x: NumberParam, y: NumberParam, z: NumberParam): this;
|
|
11
12
|
translate(p: PointLike): this;
|
|
12
|
-
rotate(angle:
|
|
13
|
-
rotate(axis: AxisLike, angle:
|
|
13
|
+
rotate(angle: NumberParam): this;
|
|
14
|
+
rotate(axis: AxisLike, angle: NumberParam): this;
|
|
14
15
|
mirror(plane: PlaneLike): this;
|
|
15
16
|
mirror(axis: AxisLike): this;
|
|
16
17
|
}
|