fluidcad 0.0.35 → 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/bin/commands/login.js +33 -5
- package/bin/commands/mcp.js +3 -2
- package/bin/commands/publish.js +103 -8
- package/bin/lib/api-client.js +8 -0
- package/bin/lib/model-config.js +27 -4
- package/bin/lib/prompt.js +97 -0
- package/llm-docs/api/index.json +1 -1
- package/llm-docs/index.json +1 -1
- package/package.json +1 -1
- package/server/dist/fluidcad-server.d.ts +19 -0
- package/server/dist/fluidcad-server.js +30 -0
- package/server/dist/model-package/pack.js +7 -6
- package/server/dist/model-package/types.d.ts +4 -3
package/bin/commands/login.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import http from 'http';
|
|
2
|
+
import os from 'os';
|
|
2
3
|
import { randomBytes, createHash } from 'crypto';
|
|
3
4
|
import { getHubUrl, writeCredentials } from '../lib/config.js';
|
|
4
5
|
import { HubClient } from '../lib/api-client.js';
|
|
6
|
+
import { readPackageVersion } from '../lib/workspace.js';
|
|
5
7
|
import { openBrowser } from '../lib/browser.js';
|
|
6
8
|
|
|
7
9
|
const CLIENT_ID = 'fluidcad-cli';
|
|
@@ -11,6 +13,26 @@ function base64url(buf) {
|
|
|
11
13
|
return buf.toString('base64url');
|
|
12
14
|
}
|
|
13
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
|
+
|
|
14
36
|
/**
|
|
15
37
|
* Loopback + PKCE login (RFC 8252): spin up a one-shot 127.0.0.1 server, send
|
|
16
38
|
* the browser to the hub's /cli/authorize, receive the code on /callback,
|
|
@@ -42,15 +64,16 @@ function runLogin(opts) {
|
|
|
42
64
|
res.end();
|
|
43
65
|
return;
|
|
44
66
|
}
|
|
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
67
|
const error = url.searchParams.get('error');
|
|
51
68
|
const code = url.searchParams.get('code');
|
|
52
69
|
const returnedState = url.searchParams.get('state');
|
|
53
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
|
+
|
|
54
77
|
(async () => {
|
|
55
78
|
if (error) {
|
|
56
79
|
throw new Error(`authorization ${error}`);
|
|
@@ -87,6 +110,7 @@ function runLogin(opts) {
|
|
|
87
110
|
server.listen(0, '127.0.0.1', () => {
|
|
88
111
|
const { port } = server.address();
|
|
89
112
|
redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
113
|
+
const device = deviceHints();
|
|
90
114
|
const authorizeUrl =
|
|
91
115
|
`${hubUrl}/cli/authorize?` +
|
|
92
116
|
new URLSearchParams({
|
|
@@ -96,6 +120,10 @@ function runLogin(opts) {
|
|
|
96
120
|
code_challenge: challenge,
|
|
97
121
|
code_challenge_method: 'S256',
|
|
98
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,
|
|
99
127
|
});
|
|
100
128
|
console.log('Opening your browser to authorize the FluidCAD CLI…');
|
|
101
129
|
console.log(`\n ${authorizeUrl}\n`);
|
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);
|
package/bin/commands/publish.js
CHANGED
|
@@ -2,7 +2,8 @@ import { resolve } from 'path';
|
|
|
2
2
|
import { getHubUrl, readCredentials } from '../lib/config.js';
|
|
3
3
|
import { HubClient } from '../lib/api-client.js';
|
|
4
4
|
import { findEntry, readPackageVersion, readWorkspacePackage } from '../lib/workspace.js';
|
|
5
|
-
import {
|
|
5
|
+
import { readModelIdentity, writeModelConfig } from '../lib/model-config.js';
|
|
6
|
+
import { isInteractive, select } from '../lib/prompt.js';
|
|
6
7
|
import { openBrowser } from '../lib/browser.js';
|
|
7
8
|
|
|
8
9
|
async function runPublish(opts) {
|
|
@@ -20,7 +21,31 @@ async function runPublish(opts) {
|
|
|
20
21
|
const pkg = readWorkspacePackage(workspace);
|
|
21
22
|
const name = opts.name ?? pkg.name;
|
|
22
23
|
const description = opts.description ?? pkg.description;
|
|
23
|
-
const modelId =
|
|
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
|
+
);
|
|
24
49
|
|
|
25
50
|
// Render once to capture the full param schema for the manifest. This also
|
|
26
51
|
// acts as a build gate — a compile/runtime error fails the publish here,
|
|
@@ -66,8 +91,8 @@ async function runPublish(opts) {
|
|
|
66
91
|
|
|
67
92
|
const form = new FormData();
|
|
68
93
|
form.append('fluidpkg', new Blob([zip], { type: 'application/zip' }), 'model.fluidpkg');
|
|
69
|
-
if (
|
|
70
|
-
form.append('modelId',
|
|
94
|
+
if (targetModelId) {
|
|
95
|
+
form.append('modelId', targetModelId);
|
|
71
96
|
}
|
|
72
97
|
if (name) {
|
|
73
98
|
form.append('name', name);
|
|
@@ -90,10 +115,12 @@ async function runPublish(opts) {
|
|
|
90
115
|
throw new Error(body.error || `Publish failed (HTTP ${status})`);
|
|
91
116
|
}
|
|
92
117
|
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
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 });
|
|
97
124
|
}
|
|
98
125
|
|
|
99
126
|
console.log('');
|
|
@@ -113,6 +140,72 @@ async function runPublish(opts) {
|
|
|
113
140
|
}
|
|
114
141
|
}
|
|
115
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
|
+
|
|
116
209
|
export function registerPublishCommand(program) {
|
|
117
210
|
program
|
|
118
211
|
.command('publish')
|
|
@@ -121,6 +214,8 @@ export function registerPublishCommand(program) {
|
|
|
121
214
|
.option('-e, --entry <file>', 'Entry .fluid.js file (auto-detected if only one exists)')
|
|
122
215
|
.option('-n, --name <name>', 'Model name (defaults to the package name)')
|
|
123
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')
|
|
124
219
|
.option('--visibility <visibility>', 'public | unlisted | private (default: unlisted)')
|
|
125
220
|
.option('--hub <url>', 'Hub base URL (default: the hub you logged into)')
|
|
126
221
|
.action((opts) => {
|
package/bin/lib/api-client.js
CHANGED
|
@@ -20,6 +20,14 @@ export class HubClient {
|
|
|
20
20
|
return { status: res.status, body };
|
|
21
21
|
}
|
|
22
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
|
+
|
|
23
31
|
async postJson(path, body) {
|
|
24
32
|
const res = await fetch(this.base + path, {
|
|
25
33
|
method: 'POST',
|
package/bin/lib/model-config.js
CHANGED
|
@@ -28,11 +28,34 @@ export function readModelId(workspace) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
31
|
+
* The persisted identity for this workspace: the hub model `id` and its
|
|
32
|
+
* last-known `name`. Both come back null when absent — first publish, or a
|
|
33
|
+
* `fluidcad.json` the user deleted. The name is shown in the publish prompt so
|
|
34
|
+
* we can name the model offline without a round-trip.
|
|
33
35
|
*/
|
|
34
|
-
export function
|
|
36
|
+
export function readModelIdentity(workspace) {
|
|
37
|
+
const cfg = readModelConfig(workspace);
|
|
38
|
+
return {
|
|
39
|
+
modelId: typeof cfg.modelId === 'string' && cfg.modelId ? cfg.modelId : null,
|
|
40
|
+
name: typeof cfg.name === 'string' && cfg.name ? cfg.name : null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Shallow-merge `patch` into `fluidcad.json`, preserving any other fields, and
|
|
46
|
+
* write it back (creating the file if needed). `undefined`/`null` values in the
|
|
47
|
+
* patch are skipped, so callers can pass a partial identity without clobbering
|
|
48
|
+
* what's already there.
|
|
49
|
+
*/
|
|
50
|
+
export function writeModelConfig(workspace, patch) {
|
|
35
51
|
const cfg = readModelConfig(workspace);
|
|
36
|
-
|
|
52
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
53
|
+
if (value !== undefined && value !== null) cfg[key] = value;
|
|
54
|
+
}
|
|
37
55
|
writeFileSync(modelConfigPath(workspace), JSON.stringify(cfg, null, 2) + '\n');
|
|
38
56
|
}
|
|
57
|
+
|
|
58
|
+
/** Persist just the hub-minted model id (thin wrapper over `writeModelConfig`). */
|
|
59
|
+
export function writeModelId(workspace, modelId) {
|
|
60
|
+
writeModelConfig(workspace, { modelId });
|
|
61
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { emitKeypressEvents } from 'readline';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal interactive prompts for the CLI, built on Node's built-in `readline`
|
|
5
|
+
* keypress events — no dependency (the CLI only ships `commander`). Used by
|
|
6
|
+
* `publish` to choose between a new model and a new version.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether we can run an interactive prompt — both stdin and stdout must be a
|
|
11
|
+
* TTY. CI and piped input are not, so callers fall back to flags/defaults there.
|
|
12
|
+
*/
|
|
13
|
+
export function isInteractive() {
|
|
14
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const HIDE_CURSOR = '\x1b[?25l';
|
|
18
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
19
|
+
const CLEAR_LINE = '\x1b[2K';
|
|
20
|
+
const CYAN = '\x1b[36m';
|
|
21
|
+
const DIM = '\x1b[2m';
|
|
22
|
+
const RESET = '\x1b[0m';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Arrow-key (or j/k) single-select. Renders a menu with one highlighted row,
|
|
26
|
+
* moves the highlight on ↑/↓/k/j, confirms on Enter, and aborts on Ctrl-C.
|
|
27
|
+
* Resolves to the chosen entry's `value`. Assumes `isInteractive()` — it puts
|
|
28
|
+
* stdin in raw mode, so don't call it without a TTY.
|
|
29
|
+
*
|
|
30
|
+
* `choices`: `[{ label, value }]`.
|
|
31
|
+
*/
|
|
32
|
+
export function select(message, choices) {
|
|
33
|
+
return new Promise((resolveChoice) => {
|
|
34
|
+
const input = process.stdin;
|
|
35
|
+
const output = process.stdout;
|
|
36
|
+
let active = 0;
|
|
37
|
+
|
|
38
|
+
// Truncate labels so a long one never wraps — a wrapped row would occupy two
|
|
39
|
+
// terminal lines and throw off the cursor-up redraw math below.
|
|
40
|
+
const width = Math.max(8, (output.columns || 80) - 2);
|
|
41
|
+
const fit = (s) => (s.length > width ? s.slice(0, width - 1) + '…' : s);
|
|
42
|
+
|
|
43
|
+
output.write(`${message}\n`);
|
|
44
|
+
output.write(`${DIM} ↑/↓ or j/k to move · Enter to confirm${RESET}\n`);
|
|
45
|
+
output.write(HIDE_CURSOR);
|
|
46
|
+
|
|
47
|
+
const draw = (initial) => {
|
|
48
|
+
if (!initial) output.write(`\x1b[${choices.length}A`); // back up to the first row
|
|
49
|
+
choices.forEach((c, i) => {
|
|
50
|
+
const on = i === active;
|
|
51
|
+
const row = `${on ? '❯' : ' '} ${fit(c.label)}`;
|
|
52
|
+
output.write(`${CLEAR_LINE}${on ? `${CYAN}${row}${RESET}` : row}\n`);
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
draw(true);
|
|
56
|
+
|
|
57
|
+
emitKeypressEvents(input);
|
|
58
|
+
const wasRaw = Boolean(input.isRaw);
|
|
59
|
+
input.setRawMode(true);
|
|
60
|
+
input.resume();
|
|
61
|
+
|
|
62
|
+
const restore = () => {
|
|
63
|
+
input.removeListener('keypress', onKey);
|
|
64
|
+
input.setRawMode(wasRaw);
|
|
65
|
+
input.pause();
|
|
66
|
+
output.write(SHOW_CURSOR);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const onKey = (_str, key) => {
|
|
70
|
+
if (!key) return;
|
|
71
|
+
if (key.ctrl && key.name === 'c') {
|
|
72
|
+
restore();
|
|
73
|
+
output.write('\n');
|
|
74
|
+
process.exit(130); // 128 + SIGINT, the shell convention for Ctrl-C
|
|
75
|
+
}
|
|
76
|
+
switch (key.name) {
|
|
77
|
+
case 'up':
|
|
78
|
+
case 'k':
|
|
79
|
+
active = (active - 1 + choices.length) % choices.length;
|
|
80
|
+
draw(false);
|
|
81
|
+
break;
|
|
82
|
+
case 'down':
|
|
83
|
+
case 'j':
|
|
84
|
+
active = (active + 1) % choices.length;
|
|
85
|
+
draw(false);
|
|
86
|
+
break;
|
|
87
|
+
case 'return':
|
|
88
|
+
case 'enter':
|
|
89
|
+
restore();
|
|
90
|
+
resolveChoice(choices[active].value);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
input.on('keypress', onKey);
|
|
96
|
+
});
|
|
97
|
+
}
|
package/llm-docs/api/index.json
CHANGED
package/llm-docs/index.json
CHANGED
package/package.json
CHANGED
|
@@ -125,6 +125,25 @@ export declare class FluidCadServer {
|
|
|
125
125
|
data: string | Uint8Array;
|
|
126
126
|
fileName: string;
|
|
127
127
|
} | null;
|
|
128
|
+
/**
|
|
129
|
+
* Export every solid of a hub session's latest render. The session-keyed twin
|
|
130
|
+
* of `exportShapes` (which reads the desktop `currentFileName`): hub mode keys
|
|
131
|
+
* each render's scene by `sessionId`, so exporting/downloading from a hub
|
|
132
|
+
* session must look it up the same way — exactly why `hitTestForSession`
|
|
133
|
+
* exists. Gathers all solids itself ("download the whole model"); returns null
|
|
134
|
+
* when the session has no rendered scene or it holds no solids (the caller maps
|
|
135
|
+
* that to a "nothing to export" response).
|
|
136
|
+
*/
|
|
137
|
+
exportShapesForSession(sessionId: string, options: {
|
|
138
|
+
format: 'step' | 'stl';
|
|
139
|
+
includeColors?: boolean;
|
|
140
|
+
resolution?: string;
|
|
141
|
+
customLinearDeflection?: number;
|
|
142
|
+
customAngularDeflectionDeg?: number;
|
|
143
|
+
}): {
|
|
144
|
+
data: string | Uint8Array;
|
|
145
|
+
fileName: string;
|
|
146
|
+
} | null;
|
|
128
147
|
hitTest(shapeId: string, rayOrigin: [number, number, number], rayDir: [number, number, number], edgeThreshold: number): any;
|
|
129
148
|
hitTestForSession(sessionId: string, shapeId: string, rayOrigin: [number, number, number], rayDir: [number, number, number], edgeThreshold: number): any;
|
|
130
149
|
setCompileError(err: CompileError | null): void;
|
|
@@ -295,6 +295,36 @@ export class FluidCadServer {
|
|
|
295
295
|
}
|
|
296
296
|
return this.sceneManager.exportShapes(scene, shapeIds, options);
|
|
297
297
|
}
|
|
298
|
+
/**
|
|
299
|
+
* Export every solid of a hub session's latest render. The session-keyed twin
|
|
300
|
+
* of `exportShapes` (which reads the desktop `currentFileName`): hub mode keys
|
|
301
|
+
* each render's scene by `sessionId`, so exporting/downloading from a hub
|
|
302
|
+
* session must look it up the same way — exactly why `hitTestForSession`
|
|
303
|
+
* exists. Gathers all solids itself ("download the whole model"); returns null
|
|
304
|
+
* when the session has no rendered scene or it holds no solids (the caller maps
|
|
305
|
+
* that to a "nothing to export" response).
|
|
306
|
+
*/
|
|
307
|
+
exportShapesForSession(sessionId, options) {
|
|
308
|
+
if (!this.sceneManager) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
const scene = this.previousScenes.get(sessionId);
|
|
312
|
+
if (!scene) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
const shapeIds = [];
|
|
316
|
+
for (const obj of scene.getAllSceneObjects()) {
|
|
317
|
+
for (const shape of obj.getAddedShapes()) {
|
|
318
|
+
if (shape.isSolid()) {
|
|
319
|
+
shapeIds.push(shape.id);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (shapeIds.length === 0) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
return this.sceneManager.exportShapes(scene, shapeIds, options);
|
|
327
|
+
}
|
|
298
328
|
hitTest(shapeId, rayOrigin, rayDir, edgeThreshold) {
|
|
299
329
|
if (!this.sceneManager) {
|
|
300
330
|
return null;
|
|
@@ -116,12 +116,13 @@ async function collectImportAssetPaths(workspacePath) {
|
|
|
116
116
|
await walk(workspacePath);
|
|
117
117
|
return out.sort();
|
|
118
118
|
}
|
|
119
|
-
// Enforced on top of any `.gitignore`: dependency trees
|
|
120
|
-
// (the latter would otherwise recurse into the next pack)
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
119
|
+
// Enforced on top of any `.gitignore`: dependency trees, prior pack outputs
|
|
120
|
+
// (the latter would otherwise recurse into the next pack), and `fluidcad.json`
|
|
121
|
+
// (the local hub binding — model id + name — which the hub already owns and
|
|
122
|
+
// should never ship as model source). `node_modules` is also pruned during the
|
|
123
|
+
// walk for speed. Hidden dot-entries are excluded by the walk directly (see
|
|
124
|
+
// below), so VCS metadata (`.git`) and secrets (`.env`) need no pattern here.
|
|
125
|
+
const ALWAYS_EXCLUDE = ['node_modules', '*.fluidpkg', 'fluidcad.json'];
|
|
125
126
|
// `ignore` ships a CJS `module.exports = factory`, but its bundled types use
|
|
126
127
|
// `export default`, which loses the call signature under `module: nodenext`.
|
|
127
128
|
// Pin the factory's real signature; the runtime value is the callable factory.
|
|
@@ -43,9 +43,10 @@ export interface ModelPackageManifest {
|
|
|
43
43
|
* brep/STEP) are NOT repeated here; `files` + `assets` is the whole package.
|
|
44
44
|
*
|
|
45
45
|
* Selection respects a root `.gitignore` (via the `ignore` package) and
|
|
46
|
-
* always excludes `node_modules`, prior `*.fluidpkg` outputs,
|
|
47
|
-
* hidden dot-entry (`.git`, `.env`,
|
|
48
|
-
* content, may hold secrets), whether or
|
|
46
|
+
* always excludes `node_modules`, prior `*.fluidpkg` outputs, `fluidcad.json`
|
|
47
|
+
* (the local hub binding), and every hidden dot-entry (`.git`, `.env`,
|
|
48
|
+
* `.claude`, `.vscode`, … — never model content, may hold secrets), whether or
|
|
49
|
+
* not they're gitignored.
|
|
49
50
|
*/
|
|
50
51
|
files: string[];
|
|
51
52
|
params?: Record<string, ParamValue>;
|