@tuongaz/seeflow 0.1.116 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/web/assets/{architectureDiagram-3BPJPVTR-ChS0qb2x.js → architectureDiagram-3BPJPVTR-BP4T6mA1.js} +1 -1
  2. package/dist/web/assets/{blockDiagram-GPEHLZMM-ClsRJfbP.js → blockDiagram-GPEHLZMM-BtNtXeK-.js} +1 -1
  3. package/dist/web/assets/{c4Diagram-AAUBKEIU-DraVWb1q.js → c4Diagram-AAUBKEIU-zllILeBc.js} +1 -1
  4. package/dist/web/assets/channel-BaDcWmt9.js +1 -0
  5. package/dist/web/assets/{chart-Cl0G2q74.js → chart-CTi57A5k.js} +1 -1
  6. package/dist/web/assets/{chunk-2J33WTMH-RlnFQ5n3.js → chunk-2J33WTMH-DK91nAlB.js} +1 -1
  7. package/dist/web/assets/{chunk-4BX2VUAB-BzyRq_P8.js → chunk-4BX2VUAB-B1nZ2oYH.js} +1 -1
  8. package/dist/web/assets/{chunk-55IACEB6-GEPQm1rc.js → chunk-55IACEB6-BO1Yz65k.js} +1 -1
  9. package/dist/web/assets/{chunk-727SXJPM-C3bBzMUY.js → chunk-727SXJPM-Do2lVazW.js} +1 -1
  10. package/dist/web/assets/{chunk-AQP2D5EJ-DDvxd8ad.js → chunk-AQP2D5EJ-COAk4zWW.js} +1 -1
  11. package/dist/web/assets/{chunk-FMBD7UC4-CirSrEeW.js → chunk-FMBD7UC4-DlagJm0t.js} +1 -1
  12. package/dist/web/assets/{chunk-ND2GUHAM-Dj61HkBx.js → chunk-ND2GUHAM-MSDepakT.js} +1 -1
  13. package/dist/web/assets/{chunk-QZHKN3VN-wY1aCiCt.js → chunk-QZHKN3VN-PNcsoYy5.js} +1 -1
  14. package/dist/web/assets/classDiagram-4FO5ZUOK-CgMT8ikA.js +1 -0
  15. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-CgMT8ikA.js +1 -0
  16. package/dist/web/assets/{code-block-CWlhsgY3.js → code-block-BaLTXteb.js} +1 -1
  17. package/dist/web/assets/{cose-bilkent-S5V4N54A-Cvi7kqCH.js → cose-bilkent-S5V4N54A-EsAyOjlw.js} +1 -1
  18. package/dist/web/assets/{dagre-BM42HDAG-j8nwNHtP.js → dagre-BM42HDAG-BeczwG-p.js} +1 -1
  19. package/dist/web/assets/{diagram-2AECGRRQ-BHS5sQs2.js → diagram-2AECGRRQ-Cxt-hv3w.js} +1 -1
  20. package/dist/web/assets/{diagram-5GNKFQAL-CTtPXDpI.js → diagram-5GNKFQAL-DNd2ZjqR.js} +1 -1
  21. package/dist/web/assets/{diagram-KO2AKTUF-C3QzT83u.js → diagram-KO2AKTUF-fQnbV9uU.js} +1 -1
  22. package/dist/web/assets/{diagram-LMA3HP47-CD8FOgbM.js → diagram-LMA3HP47-BRMAcCJt.js} +1 -1
  23. package/dist/web/assets/{diagram-OG6HWLK6-BUgOtqGR.js → diagram-OG6HWLK6-BwgD3tgr.js} +1 -1
  24. package/dist/web/assets/{erDiagram-TEJ5UH35-CxmhWgYx.js → erDiagram-TEJ5UH35-D9zgPPEj.js} +1 -1
  25. package/dist/web/assets/{flowDiagram-I6XJVG4X-S_cP7gJJ.js → flowDiagram-I6XJVG4X-DeK_DVft.js} +1 -1
  26. package/dist/web/assets/{ganttDiagram-6RSMTGT7-CFL39R66.js → ganttDiagram-6RSMTGT7-D5ixoPcv.js} +1 -1
  27. package/dist/web/assets/{gitGraphDiagram-PVQCEYII-DHzQjhWY.js → gitGraphDiagram-PVQCEYII-BLGiKLuK.js} +1 -1
  28. package/dist/web/assets/{iconify-DygcsvA_.js → iconify-CfqTggJR.js} +1 -1
  29. package/dist/web/assets/{index-vrogwRu7.js → index-cPs_3Zlh.js} +393 -393
  30. package/dist/web/assets/{index.es-DCyBp83M.js → index.es-DIiHxuUF.js} +1 -1
  31. package/dist/web/assets/{infoDiagram-5YYISTIA-BIEW0vFd.js → infoDiagram-5YYISTIA-faCq5UMN.js} +1 -1
  32. package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-BTJFVfqA.js → ishikawaDiagram-YF4QCWOH-B08q3xk1.js} +1 -1
  33. package/dist/web/assets/{journeyDiagram-JHISSGLW-CjrQ6RsI.js → journeyDiagram-JHISSGLW-B3n-Ia_k.js} +1 -1
  34. package/dist/web/assets/{jspdf.es.min-YoYb8Omk.js → jspdf.es.min-ByBlwSRB.js} +3 -3
  35. package/dist/web/assets/{kanban-definition-UN3LZRKU-DjmxqRKs.js → kanban-definition-UN3LZRKU-BebFqVN1.js} +1 -1
  36. package/dist/web/assets/{linear-_5pJT7vm.js → linear-COKMY-XV.js} +1 -1
  37. package/dist/web/assets/{markdown-y_6oaD2w.js → markdown-DX0LZAiR.js} +1 -1
  38. package/dist/web/assets/{mermaid.core-BQGwXUyN.js → mermaid.core-Iw0pYMV7.js} +4 -4
  39. package/dist/web/assets/{mindmap-definition-RKZ34NQL-BDqqUc0b.js → mindmap-definition-RKZ34NQL-CudO6Clp.js} +1 -1
  40. package/dist/web/assets/{pieDiagram-4H26LBE5-dXuWIrlE.js → pieDiagram-4H26LBE5--7iM2b2h.js} +1 -1
  41. package/dist/web/assets/{quadrantDiagram-W4KKPZXB-CJNSFKIz.js → quadrantDiagram-W4KKPZXB-DoEfRS67.js} +1 -1
  42. package/dist/web/assets/{requirementDiagram-4Y6WPE33-DwlMtj0S.js → requirementDiagram-4Y6WPE33-CQCw-vbx.js} +1 -1
  43. package/dist/web/assets/{sankeyDiagram-5OEKKPKP-MJWxcCKy.js → sankeyDiagram-5OEKKPKP-D14wCDvH.js} +1 -1
  44. package/dist/web/assets/{sequenceDiagram-3UESZ5HK-CyjlXmJF.js → sequenceDiagram-3UESZ5HK-_l1PC8kz.js} +1 -1
  45. package/dist/web/assets/{stateDiagram-AJRCARHV-CKszvTuC.js → stateDiagram-AJRCARHV-BASnBha4.js} +1 -1
  46. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-B22a_tep.js +1 -0
  47. package/dist/web/assets/{time-BxtQjMAy.js → time-IFC44Ku-.js} +1 -1
  48. package/dist/web/assets/{timeline-definition-PNZ67QCA-NQ4BeV8V.js → timeline-definition-PNZ67QCA-BMzri7cw.js} +1 -1
  49. package/dist/web/assets/{vennDiagram-CIIHVFJN-CERW1eu-.js → vennDiagram-CIIHVFJN-DALKnV47.js} +1 -1
  50. package/dist/web/assets/{wardley-L42UT6IY-CYU6l-ee.js → wardley-L42UT6IY-DASxbKrT.js} +1 -1
  51. package/dist/web/assets/{wardleyDiagram-YWT4CUSO-CRJfy1am.js → wardleyDiagram-YWT4CUSO-BAWZ9yXf.js} +1 -1
  52. package/dist/web/assets/{xychartDiagram-2RQKCTM6-PQ_cRQCE.js → xychartDiagram-2RQKCTM6-BaKX6VQd.js} +1 -1
  53. package/dist/web/index.html +1 -1
  54. package/package.json +1 -1
  55. package/src/cli-auth.ts +70 -0
  56. package/src/cli-manifest.ts +25 -0
  57. package/src/cli.ts +72 -0
  58. package/src/cloud-login.ts +82 -0
  59. package/src/cloud-meta.ts +42 -0
  60. package/src/credentials.ts +84 -0
  61. package/src/events.ts +25 -16
  62. package/src/export-bundle.ts +78 -0
  63. package/src/paths.ts +12 -3
  64. package/src/publish.ts +55 -0
  65. package/src/server.ts +19 -0
  66. package/src/tenancy.ts +80 -0
  67. package/dist/web/assets/channel-CFhAGsWg.js +0 -1
  68. package/dist/web/assets/classDiagram-4FO5ZUOK-Bn7A0H77.js +0 -1
  69. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-Bn7A0H77.js +0 -1
  70. 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 event published
3
- * for that demo until they unsubscribe; subscribers for other demos are not
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 specific demo. Returns an unsubscribe fn. */
29
- subscribe(flowId: string, fn: Subscriber): () => void;
30
- /** Broadcast an event to all subscribers of `flowId`. */
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 given demo (used in tests). */
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
- let set = subs.get(flowId);
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(flowId, set);
52
+ subs.set(key, set);
45
53
  }
46
54
  set.add(fn);
47
55
  return () => {
48
- const current = subs.get(flowId);
56
+ const current = subs.get(key);
49
57
  if (!current) return;
50
58
  current.delete(fn);
51
- if (current.size === 0) subs.delete(flowId);
59
+ if (current.size === 0) subs.delete(key);
52
60
  };
53
61
  },
54
62
  broadcast(event) {
55
- const set = subs.get(event.flowId);
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
- if (workspace && workspace.length > 0) return join(workspace, '.seeflow');
12
- return join(homedir(), '.seeflow');
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).