@tuongaz/seeflow 0.1.116 → 0.2.3
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/dist/web/assets/{architectureDiagram-3BPJPVTR-ChS0qb2x.js → architectureDiagram-3BPJPVTR-D_K4WWP-.js} +1 -1
- package/dist/web/assets/{blockDiagram-GPEHLZMM-ClsRJfbP.js → blockDiagram-GPEHLZMM-BBRZF7xo.js} +1 -1
- package/dist/web/assets/{c4Diagram-AAUBKEIU-DraVWb1q.js → c4Diagram-AAUBKEIU-L2p6fADW.js} +1 -1
- package/dist/web/assets/channel-CBju5QaN.js +1 -0
- package/dist/web/assets/{chart-Cl0G2q74.js → chart-YnnKsrqR.js} +1 -1
- package/dist/web/assets/{chunk-2J33WTMH-RlnFQ5n3.js → chunk-2J33WTMH-C1FtMVln.js} +1 -1
- package/dist/web/assets/{chunk-4BX2VUAB-BzyRq_P8.js → chunk-4BX2VUAB-DqbhUUVX.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-GEPQm1rc.js → chunk-55IACEB6-D4YZnYWz.js} +1 -1
- package/dist/web/assets/{chunk-727SXJPM-C3bBzMUY.js → chunk-727SXJPM-CoIKqKcl.js} +1 -1
- package/dist/web/assets/{chunk-AQP2D5EJ-DDvxd8ad.js → chunk-AQP2D5EJ-yLEJhNlX.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-CirSrEeW.js → chunk-FMBD7UC4-CFOGJUHx.js} +1 -1
- package/dist/web/assets/{chunk-ND2GUHAM-Dj61HkBx.js → chunk-ND2GUHAM-BH47YF_0.js} +1 -1
- package/dist/web/assets/{chunk-QZHKN3VN-wY1aCiCt.js → chunk-QZHKN3VN-DnD2ATN8.js} +1 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-DUsSbBRv.js +1 -0
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-DUsSbBRv.js +1 -0
- package/dist/web/assets/{code-block-CWlhsgY3.js → code-block-g3hkOwqd.js} +1 -1
- package/dist/web/assets/{cose-bilkent-S5V4N54A-Cvi7kqCH.js → cose-bilkent-S5V4N54A-CI-J1C6c.js} +1 -1
- package/dist/web/assets/{dagre-BM42HDAG-j8nwNHtP.js → dagre-BM42HDAG-bt5b1f32.js} +1 -1
- package/dist/web/assets/{diagram-2AECGRRQ-BHS5sQs2.js → diagram-2AECGRRQ-Dt_YPspQ.js} +1 -1
- package/dist/web/assets/{diagram-5GNKFQAL-CTtPXDpI.js → diagram-5GNKFQAL-DCpp4jJO.js} +1 -1
- package/dist/web/assets/{diagram-KO2AKTUF-C3QzT83u.js → diagram-KO2AKTUF-CJx0SJcp.js} +1 -1
- package/dist/web/assets/{diagram-LMA3HP47-CD8FOgbM.js → diagram-LMA3HP47-B-Px4zgh.js} +1 -1
- package/dist/web/assets/{diagram-OG6HWLK6-BUgOtqGR.js → diagram-OG6HWLK6-DcMvFol1.js} +1 -1
- package/dist/web/assets/{erDiagram-TEJ5UH35-CxmhWgYx.js → erDiagram-TEJ5UH35-pY73Wieh.js} +1 -1
- package/dist/web/assets/{flowDiagram-I6XJVG4X-S_cP7gJJ.js → flowDiagram-I6XJVG4X-Dg6SmR4-.js} +1 -1
- package/dist/web/assets/{ganttDiagram-6RSMTGT7-CFL39R66.js → ganttDiagram-6RSMTGT7-BF3fXtIK.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-PVQCEYII-DHzQjhWY.js → gitGraphDiagram-PVQCEYII-N_R9uNIC.js} +1 -1
- package/dist/web/assets/{iconify-DygcsvA_.js → iconify-DbiCtyWM.js} +1 -1
- package/dist/web/assets/{index-vrogwRu7.js → index-BpnbJWOx.js} +393 -393
- package/dist/web/assets/{index.es-DCyBp83M.js → index.es-DaMi_NRR.js} +1 -1
- package/dist/web/assets/{infoDiagram-5YYISTIA-BIEW0vFd.js → infoDiagram-5YYISTIA-DTtTiY9K.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-BTJFVfqA.js → ishikawaDiagram-YF4QCWOH-CokDbOfw.js} +1 -1
- package/dist/web/assets/{journeyDiagram-JHISSGLW-CjrQ6RsI.js → journeyDiagram-JHISSGLW-DlLpDuAT.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-YoYb8Omk.js → jspdf.es.min-KIHFW2LN.js} +3 -3
- package/dist/web/assets/{kanban-definition-UN3LZRKU-DjmxqRKs.js → kanban-definition-UN3LZRKU-DCQbOq6T.js} +1 -1
- package/dist/web/assets/{linear-_5pJT7vm.js → linear-v8WYWOTk.js} +1 -1
- package/dist/web/assets/{markdown-y_6oaD2w.js → markdown-BQOIqSv0.js} +1 -1
- package/dist/web/assets/{mermaid.core-BQGwXUyN.js → mermaid.core-DSZBo6BH.js} +4 -4
- package/dist/web/assets/{mindmap-definition-RKZ34NQL-BDqqUc0b.js → mindmap-definition-RKZ34NQL-CjabZ48y.js} +1 -1
- package/dist/web/assets/{pieDiagram-4H26LBE5-dXuWIrlE.js → pieDiagram-4H26LBE5-CC5szRjA.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-W4KKPZXB-CJNSFKIz.js → quadrantDiagram-W4KKPZXB-CTdFIR__.js} +1 -1
- package/dist/web/assets/{requirementDiagram-4Y6WPE33-DwlMtj0S.js → requirementDiagram-4Y6WPE33-uX-H7O8Z.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-5OEKKPKP-MJWxcCKy.js → sankeyDiagram-5OEKKPKP-JcIeADyT.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-3UESZ5HK-CyjlXmJF.js → sequenceDiagram-3UESZ5HK-BkEjX91O.js} +1 -1
- package/dist/web/assets/{stateDiagram-AJRCARHV-CKszvTuC.js → stateDiagram-AJRCARHV-BpuXKpGM.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-CO7sZK57.js +1 -0
- package/dist/web/assets/{time-BxtQjMAy.js → time-DXqZ8QO3.js} +1 -1
- package/dist/web/assets/{timeline-definition-PNZ67QCA-NQ4BeV8V.js → timeline-definition-PNZ67QCA-CoOJ2nzo.js} +1 -1
- package/dist/web/assets/{vennDiagram-CIIHVFJN-CERW1eu-.js → vennDiagram-CIIHVFJN-CsEPlEIu.js} +1 -1
- package/dist/web/assets/{wardley-L42UT6IY-CYU6l-ee.js → wardley-L42UT6IY-TgWBnJsd.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-YWT4CUSO-CRJfy1am.js → wardleyDiagram-YWT4CUSO-AGW0hV7t.js} +1 -1
- package/dist/web/assets/{xychartDiagram-2RQKCTM6-PQ_cRQCE.js → xychartDiagram-2RQKCTM6-Bqlg3VJX.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/cli-auth.ts +70 -0
- package/src/cli-manifest.ts +25 -0
- package/src/cli.ts +72 -0
- package/src/cloud-login.ts +82 -0
- package/src/cloud-meta.ts +42 -0
- package/src/credentials.ts +84 -0
- package/src/events.ts +25 -16
- package/src/export-bundle.ts +78 -0
- package/src/paths.ts +12 -3
- package/src/publish.ts +55 -0
- package/src/server.ts +19 -0
- package/src/tenancy.ts +80 -0
- package/dist/web/assets/channel-CFhAGsWg.js +0 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-Bn7A0H77.js +0 -1
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-Bn7A0H77.js +0 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-DEaCewos.js +0 -1
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { runLogin, runLogout, runWhoami } from './cli-auth.ts';
|
|
4
5
|
import {
|
|
5
6
|
drainStdin,
|
|
6
7
|
loadBody,
|
|
@@ -11,7 +12,9 @@ import {
|
|
|
11
12
|
} from './cli-helpers.ts';
|
|
12
13
|
import { COMMAND_MANIFEST, renderCommandHelp, renderCommandList } from './cli-manifest.ts';
|
|
13
14
|
import { createCliOperations, registerProject } from './cli-ops.ts';
|
|
15
|
+
import { DEFAULT_CLOUD_ENDPOINT } from './credentials.ts';
|
|
14
16
|
import { createEventBus } from './events.ts';
|
|
17
|
+
import { bundleProject } from './export-bundle.ts';
|
|
15
18
|
import { JqError, applyJq } from './jq-filter.ts';
|
|
16
19
|
import type { LayoutOptions } from './layout.ts';
|
|
17
20
|
import {
|
|
@@ -22,6 +25,7 @@ import {
|
|
|
22
25
|
} from './operations.ts';
|
|
23
26
|
import { PROJECT_FLOW_FILENAME, seeflowHome } from './paths.ts';
|
|
24
27
|
import { defaultProcessSpawner } from './process-spawner.ts';
|
|
28
|
+
import { publishProject } from './publish.ts';
|
|
25
29
|
import { type Registry, createRegistry, manifestOnlyEntryFilter } from './registry.ts';
|
|
26
30
|
import {
|
|
27
31
|
DEFAULT_CONFIG,
|
|
@@ -230,6 +234,14 @@ if (argv.includes('--version') || argv.includes('-v')) {
|
|
|
230
234
|
await runE2e();
|
|
231
235
|
} else if (sub === 'emit') {
|
|
232
236
|
await runEmit();
|
|
237
|
+
} else if (sub === 'login') {
|
|
238
|
+
await runLoginCmd();
|
|
239
|
+
} else if (sub === 'logout') {
|
|
240
|
+
runLogoutCmd();
|
|
241
|
+
} else if (sub === 'whoami') {
|
|
242
|
+
runWhoamiCmd();
|
|
243
|
+
} else if (sub === 'export') {
|
|
244
|
+
await runExportCmd();
|
|
233
245
|
} else {
|
|
234
246
|
console.error(`Unknown subcommand: ${sub}`);
|
|
235
247
|
printHelp();
|
|
@@ -300,6 +312,14 @@ Commands (require a running studio):
|
|
|
300
312
|
[--run-id <id>] [--payload <json>] [--studio-url <url>]
|
|
301
313
|
e2e End-to-end validate a registered flow (--project <p> --flow <f> [--skip-nodes a,b])
|
|
302
314
|
|
|
315
|
+
Cloud account:
|
|
316
|
+
login Sign in to the cloud (opens a browser) [--endpoint <url>]
|
|
317
|
+
logout Clear the stored cloud credential [--endpoint <url>]
|
|
318
|
+
whoami Show the stored cloud identity [--endpoint <url>]
|
|
319
|
+
export [path] Bundle a project (default: cwd) and export it to the cloud,
|
|
320
|
+
creating it on first run and updating in place after
|
|
321
|
+
[--endpoint <url>] [--dry-run]
|
|
322
|
+
|
|
303
323
|
Meta:
|
|
304
324
|
version Print the CLI version
|
|
305
325
|
help Show this help message
|
|
@@ -1331,3 +1351,55 @@ async function runIconsRemove() {
|
|
|
1331
1351
|
removeIconPack(vendor, { cacheRoot: iconCacheRoot() });
|
|
1332
1352
|
printOk({ removed: vendor });
|
|
1333
1353
|
}
|
|
1354
|
+
|
|
1355
|
+
// --- Cloud account verbs (generic, provider-agnostic) -----------------------
|
|
1356
|
+
// Declared as a hoisted function (not a const arrow) so the cloud command
|
|
1357
|
+
// handlers, which are invoked from the top-level dispatch above, can reach it
|
|
1358
|
+
// without tripping the temporal-dead-zone on the const binding.
|
|
1359
|
+
function cloudEndpoint(): string {
|
|
1360
|
+
return flagValue('endpoint') ?? process.env.SEEFLOW_CLOUD_URL ?? DEFAULT_CLOUD_ENDPOINT;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
async function runLoginCmd() {
|
|
1364
|
+
const endpoint = cloudEndpoint();
|
|
1365
|
+
console.error(`Opening ${endpoint} to sign in… (a browser window should open)`);
|
|
1366
|
+
try {
|
|
1367
|
+
const outcome = await runLogin({ endpoint });
|
|
1368
|
+
printOk({
|
|
1369
|
+
loggedIn: true,
|
|
1370
|
+
endpoint: outcome.endpoint,
|
|
1371
|
+
userId: outcome.userId,
|
|
1372
|
+
email: outcome.email,
|
|
1373
|
+
});
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
printError(`Login failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function runLogoutCmd() {
|
|
1380
|
+
const endpoint = cloudEndpoint();
|
|
1381
|
+
runLogout(endpoint);
|
|
1382
|
+
printOk({ loggedOut: true, endpoint });
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function runWhoamiCmd() {
|
|
1386
|
+
printOk(runWhoami(cloudEndpoint()));
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
async function runExportCmd() {
|
|
1390
|
+
const root = resolve(positionalArgs()[0] ?? '.');
|
|
1391
|
+
const endpoint = cloudEndpoint();
|
|
1392
|
+
// --dry-run bundles the project without any network call (no credential
|
|
1393
|
+
// needed) — useful for inspecting what would be shipped.
|
|
1394
|
+
if (hasFlag('dry-run')) {
|
|
1395
|
+
const bundle = bundleProject(root);
|
|
1396
|
+
printOk({ dryRun: true, endpoint, name: bundle.name, files: bundle.files.map((f) => f.path) });
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
try {
|
|
1400
|
+
const { projectId } = await publishProject({ root, baseUrl: endpoint });
|
|
1401
|
+
printOk({ exported: true, endpoint, projectId });
|
|
1402
|
+
} catch (err) {
|
|
1403
|
+
printError(`Export failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { DEFAULT_CLOUD_ENDPOINT } from './credentials.ts';
|
|
2
|
+
|
|
3
|
+
export interface LoopbackLoginOptions {
|
|
4
|
+
/** Cloud base URL. Defaults to https://cloud.seeflow.dev. */
|
|
5
|
+
endpoint?: string;
|
|
6
|
+
/** Fixed loopback port. Defaults to 0 (ephemeral). */
|
|
7
|
+
port?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LoginResult {
|
|
11
|
+
token: string;
|
|
12
|
+
userId?: string;
|
|
13
|
+
email?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LoopbackLoginSession {
|
|
17
|
+
/** The URL to open in a browser to complete sign-in. */
|
|
18
|
+
loginUrl: string;
|
|
19
|
+
/** The bound loopback port. */
|
|
20
|
+
port: number;
|
|
21
|
+
/** The CSRF state echoed back by the callback. */
|
|
22
|
+
state: string;
|
|
23
|
+
/** Resolves once a valid callback delivers a token. */
|
|
24
|
+
result: Promise<LoginResult>;
|
|
25
|
+
/** Stop the loopback server (idempotent). */
|
|
26
|
+
close(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DONE_PAGE =
|
|
30
|
+
'<!doctype html><meta charset=utf-8><title>SeeFlow</title>' +
|
|
31
|
+
'<body style="font-family:system-ui;padding:3rem;text-align:center">' +
|
|
32
|
+
'<h1>You are logged in.</h1><p>You can close this window and return to the terminal.</p></body>';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Start the local-loopback login flow (provider-agnostic). Spins a tiny HTTP
|
|
36
|
+
* server on 127.0.0.1, returns the cloud /cli/login URL to open, and resolves
|
|
37
|
+
* `result` when the cloud SPA redirects/POSTs the minted token back to
|
|
38
|
+
* /callback with a matching state. Does NOT open a browser or persist — the
|
|
39
|
+
* caller (CLI / studio) does that.
|
|
40
|
+
*/
|
|
41
|
+
export function startLoopbackLogin(
|
|
42
|
+
options: LoopbackLoginOptions = {},
|
|
43
|
+
): Promise<LoopbackLoginSession> {
|
|
44
|
+
const endpoint = (options.endpoint ?? DEFAULT_CLOUD_ENDPOINT).replace(/\/+$/, '');
|
|
45
|
+
const state = crypto.randomUUID();
|
|
46
|
+
|
|
47
|
+
let resolveResult!: (r: LoginResult) => void;
|
|
48
|
+
const result = new Promise<LoginResult>((r) => {
|
|
49
|
+
resolveResult = r;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const server = Bun.serve({
|
|
53
|
+
hostname: '127.0.0.1',
|
|
54
|
+
port: options.port ?? 0,
|
|
55
|
+
fetch(req) {
|
|
56
|
+
const url = new URL(req.url);
|
|
57
|
+
if (url.pathname !== '/callback') return new Response('not found', { status: 404 });
|
|
58
|
+
if (url.searchParams.get('state') !== state) {
|
|
59
|
+
return new Response('state mismatch', { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
const token = url.searchParams.get('token');
|
|
62
|
+
if (!token) return new Response('missing token', { status: 400 });
|
|
63
|
+
resolveResult({
|
|
64
|
+
token,
|
|
65
|
+
userId: url.searchParams.get('userId') ?? undefined,
|
|
66
|
+
email: url.searchParams.get('email') ?? undefined,
|
|
67
|
+
});
|
|
68
|
+
return new Response(DONE_PAGE, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const port = server.port ?? options.port ?? 0;
|
|
73
|
+
const loginUrl = `${endpoint}/cli/login?port=${port}&state=${state}`;
|
|
74
|
+
|
|
75
|
+
return Promise.resolve({
|
|
76
|
+
loginUrl,
|
|
77
|
+
port,
|
|
78
|
+
state,
|
|
79
|
+
result,
|
|
80
|
+
close: () => server.stop(true),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { writeFileAtomic } from './atomic-write.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Local cloud-export metadata, stored at `<root>/.seeflow/cloud.json` (the
|
|
7
|
+
* project root's own `.seeflow`, distinct from the global seeflowHome()). Keyed
|
|
8
|
+
* by the cloud base URL so a single project can be exported to more than one
|
|
9
|
+
* cloud without the ids colliding. The stamped `projectId` is what makes
|
|
10
|
+
* re-export update the same cloud project in place.
|
|
11
|
+
*/
|
|
12
|
+
interface CloudMetaEntry {
|
|
13
|
+
projectId: string;
|
|
14
|
+
lastExportedAt: string;
|
|
15
|
+
}
|
|
16
|
+
type CloudMetaFile = Record<string, CloudMetaEntry>;
|
|
17
|
+
|
|
18
|
+
function metaPath(root: string): string {
|
|
19
|
+
return join(root, '.seeflow', 'cloud.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readFile(root: string): CloudMetaFile {
|
|
23
|
+
const path = metaPath(root);
|
|
24
|
+
if (!existsSync(path)) return {};
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
27
|
+
return parsed && typeof parsed === 'object' ? (parsed as CloudMetaFile) : {};
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readCloudProjectId(root: string, baseUrl: string): string | null {
|
|
34
|
+
return readFile(root)[baseUrl]?.projectId ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function writeCloudProjectId(root: string, baseUrl: string, projectId: string): void {
|
|
38
|
+
const file = readFile(root);
|
|
39
|
+
file[baseUrl] = { projectId, lastExportedAt: new Date().toISOString() };
|
|
40
|
+
mkdirSync(join(root, '.seeflow'), { recursive: true });
|
|
41
|
+
writeFileAtomic(metaPath(root), JSON.stringify(file, null, 2));
|
|
42
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/** Default hosted cloud endpoint. Overridable per call / via SEEFLOW_CLOUD_URL. */
|
|
6
|
+
export const DEFAULT_CLOUD_ENDPOINT = 'https://cloud.seeflow.dev';
|
|
7
|
+
|
|
8
|
+
export interface StoredCredential {
|
|
9
|
+
token: string;
|
|
10
|
+
userId?: string;
|
|
11
|
+
email?: string;
|
|
12
|
+
/** ISO-8601 timestamp set on save. */
|
|
13
|
+
savedAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** endpoint host -> credential. */
|
|
17
|
+
export type CredentialsFile = Record<string, StoredCredential>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Location of the shared credential file. Honors $XDG_CONFIG_HOME (Linux/XDG
|
|
21
|
+
* convention) and falls back to ~/.seeflow. Both the CLI and the local studio
|
|
22
|
+
* read/write this single file.
|
|
23
|
+
*/
|
|
24
|
+
export function credentialsPath(): string {
|
|
25
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
26
|
+
if (xdg && xdg.length > 0) return join(xdg, 'seeflow', 'credentials.json');
|
|
27
|
+
return join(homedir(), '.seeflow', 'credentials.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Normalize an endpoint URL to its host key (so trailing slashes/paths don't fork keys). */
|
|
31
|
+
export function endpointKey(endpoint: string): string {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(endpoint).host;
|
|
34
|
+
} catch {
|
|
35
|
+
return endpoint;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readCredentials(): CredentialsFile {
|
|
40
|
+
const path = credentialsPath();
|
|
41
|
+
if (!existsSync(path)) return {};
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
44
|
+
return parsed && typeof parsed === 'object' ? (parsed as CredentialsFile) : {};
|
|
45
|
+
} catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeCredentials(file: CredentialsFile): void {
|
|
51
|
+
const path = credentialsPath();
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
writeFileSync(path, JSON.stringify(file, null, 2));
|
|
54
|
+
// Tokens are secrets — owner-only.
|
|
55
|
+
chmodSync(path, 0o600);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SaveCredentialInput {
|
|
59
|
+
endpoint: string;
|
|
60
|
+
token: string;
|
|
61
|
+
userId?: string;
|
|
62
|
+
email?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function saveCredential(input: SaveCredentialInput): void {
|
|
66
|
+
const file = readCredentials();
|
|
67
|
+
file[endpointKey(input.endpoint)] = {
|
|
68
|
+
token: input.token,
|
|
69
|
+
userId: input.userId,
|
|
70
|
+
email: input.email,
|
|
71
|
+
savedAt: new Date().toISOString(),
|
|
72
|
+
};
|
|
73
|
+
writeCredentials(file);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function loadCredential(endpoint: string): StoredCredential | undefined {
|
|
77
|
+
return readCredentials()[endpointKey(endpoint)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function clearCredential(endpoint: string): void {
|
|
81
|
+
const file = readCredentials();
|
|
82
|
+
delete file[endpointKey(endpoint)];
|
|
83
|
+
writeCredentials(file);
|
|
84
|
+
}
|
package/src/events.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* In-memory pub/sub keyed by flowId. Subscribers receive every
|
|
3
|
-
* for that demo until they unsubscribe;
|
|
4
|
-
* notified.
|
|
2
|
+
* In-memory pub/sub keyed by (tenantId, flowId). Subscribers receive every
|
|
3
|
+
* event published for that (tenant, demo) until they unsubscribe; other
|
|
4
|
+
* tenants and other demos are not notified. `tenantId` is optional — omitting
|
|
5
|
+
* it uses a single shared partition (the single-tenant local studio).
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
export type StudioEventType =
|
|
@@ -16,6 +17,8 @@ export type StudioEventType =
|
|
|
16
17
|
export interface StudioEvent {
|
|
17
18
|
type: StudioEventType;
|
|
18
19
|
flowId: string;
|
|
20
|
+
/** Optional tenant partition. Undefined = the single shared partition. */
|
|
21
|
+
tenantId?: string;
|
|
19
22
|
/** Arbitrary JSON-serializable payload. Shape depends on event type. */
|
|
20
23
|
payload: unknown;
|
|
21
24
|
/** Server-side timestamp (ms since epoch). */
|
|
@@ -25,34 +28,40 @@ export interface StudioEvent {
|
|
|
25
28
|
export type Subscriber = (event: StudioEvent) => void;
|
|
26
29
|
|
|
27
30
|
export interface EventBus {
|
|
28
|
-
/** Subscribe to events for a
|
|
29
|
-
subscribe(flowId: string, fn: Subscriber): () => void;
|
|
30
|
-
/** Broadcast an event to all subscribers of
|
|
31
|
+
/** Subscribe to events for a (tenant, demo). Returns an unsubscribe fn. */
|
|
32
|
+
subscribe(flowId: string, fn: Subscriber, tenantId?: string): () => void;
|
|
33
|
+
/** Broadcast an event to all subscribers of (tenantId, flowId). */
|
|
31
34
|
broadcast(event: Omit<StudioEvent, 'ts'> & { ts?: number }): void;
|
|
32
|
-
/** Number of active subscribers for a
|
|
33
|
-
subscriberCount(flowId: string): number;
|
|
35
|
+
/** Number of active subscribers for a (tenant, demo) (used in tests). */
|
|
36
|
+
subscriberCount(flowId: string, tenantId?: string): number;
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
/** Partition key: tenant-scoped when a tenant id is present, else shared. */
|
|
40
|
+
const partitionKey = (flowId: string, tenantId?: string): string =>
|
|
41
|
+
tenantId && tenantId.length > 0 ? `${tenantId} ${flowId}` : flowId;
|
|
42
|
+
|
|
36
43
|
export function createEventBus(): EventBus {
|
|
37
44
|
const subs = new Map<string, Set<Subscriber>>();
|
|
38
45
|
|
|
39
46
|
return {
|
|
40
|
-
subscribe(flowId, fn) {
|
|
41
|
-
|
|
47
|
+
subscribe(flowId, fn, tenantId) {
|
|
48
|
+
const key = partitionKey(flowId, tenantId);
|
|
49
|
+
let set = subs.get(key);
|
|
42
50
|
if (!set) {
|
|
43
51
|
set = new Set();
|
|
44
|
-
subs.set(
|
|
52
|
+
subs.set(key, set);
|
|
45
53
|
}
|
|
46
54
|
set.add(fn);
|
|
47
55
|
return () => {
|
|
48
|
-
const current = subs.get(
|
|
56
|
+
const current = subs.get(key);
|
|
49
57
|
if (!current) return;
|
|
50
58
|
current.delete(fn);
|
|
51
|
-
if (current.size === 0) subs.delete(
|
|
59
|
+
if (current.size === 0) subs.delete(key);
|
|
52
60
|
};
|
|
53
61
|
},
|
|
54
62
|
broadcast(event) {
|
|
55
|
-
const
|
|
63
|
+
const key = partitionKey(event.flowId, event.tenantId);
|
|
64
|
+
const set = subs.get(key);
|
|
56
65
|
if (!set) return;
|
|
57
66
|
const full: StudioEvent = { ...event, ts: event.ts ?? Date.now() };
|
|
58
67
|
for (const fn of set) {
|
|
@@ -63,8 +72,8 @@ export function createEventBus(): EventBus {
|
|
|
63
72
|
}
|
|
64
73
|
}
|
|
65
74
|
},
|
|
66
|
-
subscriberCount(flowId) {
|
|
67
|
-
return subs.get(flowId)?.size ?? 0;
|
|
75
|
+
subscriberCount(flowId, tenantId) {
|
|
76
|
+
return subs.get(partitionKey(flowId, tenantId))?.size ?? 0;
|
|
68
77
|
},
|
|
69
78
|
};
|
|
70
79
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import { PROJECT_FLOW_FILENAME } from './paths.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Transport-neutral snapshot of a project: a name plus the project files keyed
|
|
7
|
+
* by their forward-slash, project-root-relative path. Pure — no network. The
|
|
8
|
+
* cloud export endpoint receives exactly this shape; the local studio is the
|
|
9
|
+
* only thing that knows how to read a project off disk.
|
|
10
|
+
*/
|
|
11
|
+
export interface BundleFile {
|
|
12
|
+
/** Forward-slash, project-root-relative path (e.g. `nodes/n1/detail.md`). */
|
|
13
|
+
path: string;
|
|
14
|
+
content: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ProjectBundle {
|
|
18
|
+
name: string;
|
|
19
|
+
files: BundleFile[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Top-level files we include verbatim when present. */
|
|
23
|
+
const TOP_LEVEL_FILES = ['flow.json', 'style.json', 'seeflow.json'] as const;
|
|
24
|
+
/** Subtrees we recurse into when present. */
|
|
25
|
+
const SUBTREES = ['nodes', 'flows'] as const;
|
|
26
|
+
|
|
27
|
+
function relPath(root: string, abs: string): string {
|
|
28
|
+
// Normalize to forward slashes so the wire format is platform-stable.
|
|
29
|
+
return relative(root, abs).split(sep).join('/');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function collectTree(root: string, dir: string, out: BundleFile[]): void {
|
|
33
|
+
for (const entry of readdirSync(dir)) {
|
|
34
|
+
const abs = join(dir, entry);
|
|
35
|
+
const st = statSync(abs);
|
|
36
|
+
if (st.isDirectory()) {
|
|
37
|
+
collectTree(root, abs, out);
|
|
38
|
+
} else if (st.isFile()) {
|
|
39
|
+
out.push({ path: relPath(root, abs), content: readFileSync(abs, 'utf8') });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function bundleProject(root: string): ProjectBundle {
|
|
45
|
+
const flowPath = join(root, PROJECT_FLOW_FILENAME);
|
|
46
|
+
if (!existsSync(flowPath)) {
|
|
47
|
+
throw new Error(`no ${PROJECT_FLOW_FILENAME} found at ${root}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const files: BundleFile[] = [];
|
|
51
|
+
for (const name of TOP_LEVEL_FILES) {
|
|
52
|
+
const abs = join(root, name);
|
|
53
|
+
if (existsSync(abs) && statSync(abs).isFile()) {
|
|
54
|
+
files.push({ path: name, content: readFileSync(abs, 'utf8') });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const sub of SUBTREES) {
|
|
58
|
+
const abs = join(root, sub);
|
|
59
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
|
60
|
+
collectTree(root, abs, files);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { name: deriveName(root, files), files };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function deriveName(root: string, files: BundleFile[]): string {
|
|
68
|
+
const flow = files.find((f) => f.path === PROJECT_FLOW_FILENAME);
|
|
69
|
+
if (flow) {
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(flow.content) as { name?: unknown };
|
|
72
|
+
if (typeof parsed.name === 'string' && parsed.name.length > 0) return parsed.name;
|
|
73
|
+
} catch {
|
|
74
|
+
// fall through to the directory name
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return root.split(sep).filter(Boolean).pop() ?? 'project';
|
|
78
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -6,10 +6,19 @@ import { join } from 'node:path';
|
|
|
6
6
|
// inside the Docker image, where it defaults to /workspace — state lands in
|
|
7
7
|
// the bind-mounted workspace so it survives `docker run --rm`. Otherwise it
|
|
8
8
|
// falls back to ~/.seeflow for local installs.
|
|
9
|
-
export function seeflowHome(): string {
|
|
9
|
+
export function seeflowHome(tenantId?: string): string {
|
|
10
10
|
const workspace = process.env.SEEFLOW_WORKSPACE;
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const base =
|
|
12
|
+
workspace && workspace.length > 0 ? join(workspace, '.seeflow') : join(homedir(), '.seeflow');
|
|
13
|
+
if (tenantId && tenantId.length > 0) {
|
|
14
|
+
// Per-tenant nesting per the cloud tenancy design (§6.1):
|
|
15
|
+
// workspace set -> <workspace>/users/<tenantId>/.seeflow
|
|
16
|
+
// home fallback -> ~/.seeflow/users/<tenantId>/.seeflow
|
|
17
|
+
// The root differs so single- and multi-tenant layouts share one tree.
|
|
18
|
+
const root = workspace && workspace.length > 0 ? workspace : join(homedir(), '.seeflow');
|
|
19
|
+
return join(root, 'users', tenantId, '.seeflow');
|
|
20
|
+
}
|
|
21
|
+
return base;
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
// Per-project layout: everything lives at the project root. The studio never
|
package/src/publish.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readCloudProjectId, writeCloudProjectId } from './cloud-meta.ts';
|
|
2
|
+
import { DEFAULT_CLOUD_ENDPOINT, loadCredential } from './credentials.ts';
|
|
3
|
+
import { bundleProject } from './export-bundle.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generic, provider-agnostic publish/export provider. Bundles a project, POSTs
|
|
7
|
+
* it to a configurable cloud endpoint with a bearer token from the shared
|
|
8
|
+
* credential store, and stamps the returned project_id back into the project's
|
|
9
|
+
* local cloud-meta so a re-export updates the same cloud project in place.
|
|
10
|
+
*
|
|
11
|
+
* No cloud/Clerk/AWS specifics live here — the only knob is `baseUrl`. The
|
|
12
|
+
* `fetch` implementation is injected so tests never touch the network.
|
|
13
|
+
*/
|
|
14
|
+
export interface PublishOptions {
|
|
15
|
+
root: string;
|
|
16
|
+
baseUrl?: string;
|
|
17
|
+
/** Explicit token. `undefined` → read from the shared credential store; an
|
|
18
|
+
* explicit `null` means "no credential" and is rejected. */
|
|
19
|
+
token?: string | null;
|
|
20
|
+
fetchImpl?: typeof fetch;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PublishResult {
|
|
24
|
+
projectId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function publishProject(opts: PublishOptions): Promise<PublishResult> {
|
|
28
|
+
const baseUrl = opts.baseUrl ?? DEFAULT_CLOUD_ENDPOINT;
|
|
29
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
30
|
+
const token = opts.token === undefined ? (loadCredential(baseUrl)?.token ?? null) : opts.token;
|
|
31
|
+
if (!token) {
|
|
32
|
+
throw new Error('not logged in — run `seeflow login` first');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const bundle = bundleProject(opts.root);
|
|
36
|
+
const existingId = readCloudProjectId(opts.root, baseUrl);
|
|
37
|
+
|
|
38
|
+
const res = await fetchImpl(`${baseUrl}/api/export`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'content-type': 'application/json',
|
|
42
|
+
authorization: `Bearer ${token}`,
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({ ...(existingId ? { projectId: existingId } : {}), bundle }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const detail = await res.text().catch(() => '');
|
|
49
|
+
throw new Error(`export failed (${res.status})${detail ? `: ${detail}` : ''}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { projectId } = (await res.json()) as { projectId: string };
|
|
53
|
+
writeCloudProjectId(opts.root, baseUrl, projectId);
|
|
54
|
+
return { projectId };
|
|
55
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { type RegistryWatcher, createRegistryWatcher } from './registry-watcher.
|
|
|
16
16
|
import { type Registry, createRegistry, manifestOnlyEntryFilter } from './registry.ts';
|
|
17
17
|
import type { Spawner } from './shellout.ts';
|
|
18
18
|
import { type StatusRunner, createStatusRunner } from './status-runner.ts';
|
|
19
|
+
import { createTenantResolver } from './tenancy.ts';
|
|
19
20
|
import { type FlowWatcher, createWatcher } from './watcher.ts';
|
|
20
21
|
|
|
21
22
|
/** Absolute path to the vendored runtime asset directory. Resolved relative
|
|
@@ -79,6 +80,11 @@ export interface CreateAppOptions {
|
|
|
79
80
|
/** Override the icon installer's fetcher. Production uses fetchWithProgress
|
|
80
81
|
* (real network); integration tests inject a fixture-returning closure. */
|
|
81
82
|
iconFetcher?: IconFetcher;
|
|
83
|
+
/** Host-injected per-request tenant resolver. Returns a tenant id (e.g. the
|
|
84
|
+
* authenticated user's id) from the request context, or undefined for the
|
|
85
|
+
* single-tenant local studio. Provider-agnostic — the studio never learns
|
|
86
|
+
* HOW the id is produced. See src/tenancy.ts. */
|
|
87
|
+
getTenantId?: (ctx: import('hono').Context) => string | undefined;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
const DEFAULT_VITE_DEV_URL = 'http://localhost:5173';
|
|
@@ -108,6 +114,8 @@ export function createApp(options: CreateAppOptions = {}): Hono {
|
|
|
108
114
|
options.statusRunner ??
|
|
109
115
|
createStatusRunner({ registry, events, spawner: defaultProcessSpawner });
|
|
110
116
|
const iconJobs = options.iconJobs ?? createJobRegistry();
|
|
117
|
+
const tenantResolver = createTenantResolver({ defaultRegistry: registry, defaultEvents: events });
|
|
118
|
+
const getTenantId = options.getTenantId;
|
|
111
119
|
|
|
112
120
|
if (watcher && (options.watchAllOnBoot ?? true)) {
|
|
113
121
|
watcher.watchAll();
|
|
@@ -118,6 +126,17 @@ export function createApp(options: CreateAppOptions = {}): Hono {
|
|
|
118
126
|
|
|
119
127
|
const app = new Hono();
|
|
120
128
|
|
|
129
|
+
// Per-request tenant context. With no getTenantId hook this resolves the
|
|
130
|
+
// default singletons (local studio). The cloud injects a hook returning
|
|
131
|
+
// user.sub so each request reads/writes its own tenant tree.
|
|
132
|
+
// Route-by-route adoption of c.get('tenant') is deferred to Phase 2.
|
|
133
|
+
app.use('*', async (c, next) => {
|
|
134
|
+
const tenantId = getTenantId ? getTenantId(c) : undefined;
|
|
135
|
+
if (tenantId) c.set('tenantId', tenantId);
|
|
136
|
+
c.set('tenant', tenantResolver.resolve(tenantId));
|
|
137
|
+
return next();
|
|
138
|
+
});
|
|
139
|
+
|
|
121
140
|
// CORS + token gate runs first so every downstream route inherits the
|
|
122
141
|
// null-origin rule. No-ops on requests without an Origin header (CLI
|
|
123
142
|
// calls, integration tests, top-level navigation).
|