latticesql 1.16.2 → 1.16.4

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/cli.js CHANGED
@@ -7,8 +7,8 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
7
7
  });
8
8
 
9
9
  // src/cli.ts
10
- import { resolve as resolve9, dirname as dirname9 } from "path";
11
- import { readFileSync as readFileSync14 } from "fs";
10
+ import { resolve as resolve10, dirname as dirname12 } from "path";
11
+ import { readFileSync as readFileSync15 } from "fs";
12
12
  import { execSync } from "child_process";
13
13
  import { parse as parse2 } from "yaml";
14
14
 
@@ -3640,7 +3640,7 @@ ${body}`;
3640
3640
 
3641
3641
  // src/framework/workspace.ts
3642
3642
  import { existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync8, renameSync as renameSync2, writeFileSync as writeFileSync4 } from "fs";
3643
- import { join as join11 } from "path";
3643
+ import { dirname as dirname6, join as join11, resolve as resolve4 } from "path";
3644
3644
  import { v4 as uuidv4 } from "uuid";
3645
3645
  var EMPTY_REGISTRY = {
3646
3646
  version: 1,
@@ -3737,6 +3737,15 @@ function setActiveWorkspace(root, id) {
3737
3737
  writeRegistry(root, reg);
3738
3738
  }
3739
3739
  function resolveWorkspacePaths(root, ws) {
3740
+ if (ws.configPath) {
3741
+ const dir = dirname6(ws.configPath);
3742
+ return {
3743
+ dir,
3744
+ configPath: ws.configPath,
3745
+ dataDir: join11(dir, "Data"),
3746
+ contextDir: ws.contextDir ?? resolve4(dir, "context")
3747
+ };
3748
+ }
3740
3749
  return {
3741
3750
  dir: workspaceDir(root, ws.dir),
3742
3751
  configPath: workspaceConfigPath(root, ws.dir),
@@ -3744,6 +3753,13 @@ function resolveWorkspacePaths(root, ws) {
3744
3753
  contextDir: workspaceContextDir(root, ws.dir)
3745
3754
  };
3746
3755
  }
3756
+ function effectiveConfigPath(root, ws) {
3757
+ return ws.configPath ?? workspaceConfigPath(root, ws.dir);
3758
+ }
3759
+ function findWorkspaceByConfigPath(root, configPath) {
3760
+ const target = resolve4(configPath);
3761
+ return listWorkspaces(root).find((w) => resolve4(effectiveConfigPath(root, w)) === target) ?? null;
3762
+ }
3747
3763
  function isCloudDb(db) {
3748
3764
  const trimmed = db.trim();
3749
3765
  return /^postgres(ql)?:\/\//i.test(trimmed) || trimmed.startsWith("${LATTICE_DB:");
@@ -3786,6 +3802,79 @@ function addWorkspace(root, opts) {
3786
3802
  writeRegistry(root, reg);
3787
3803
  return record;
3788
3804
  }
3805
+ function addAdoptedWorkspace(root, opts) {
3806
+ if (!existsSync11(rootConfigDir(root))) {
3807
+ mkdirSync6(rootConfigDir(root), { recursive: true });
3808
+ }
3809
+ const existing = findWorkspaceByConfigPath(root, opts.configPath);
3810
+ if (existing) {
3811
+ if (opts.makeActive) setActiveWorkspace(root, existing.id);
3812
+ return existing;
3813
+ }
3814
+ const reg = readRegistry(root);
3815
+ const existingDirs = new Set(reg.workspaces.map((w) => w.dir));
3816
+ const record = {
3817
+ id: uuidv4(),
3818
+ displayName: opts.displayName,
3819
+ dir: uniqueDirName(opts.displayName, existingDirs),
3820
+ db: opts.db,
3821
+ kind: isCloudDb(opts.db) ? "cloud" : "local",
3822
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3823
+ configPath: resolve4(opts.configPath),
3824
+ contextDir: resolve4(opts.contextDir)
3825
+ };
3826
+ reg.workspaces.push(record);
3827
+ const makeActive = opts.makeActive ?? reg.activeWorkspaceId === null;
3828
+ if (makeActive) reg.activeWorkspaceId = record.id;
3829
+ writeRegistry(root, reg);
3830
+ return record;
3831
+ }
3832
+ function registerOrUpdateCloudWorkspace(root, opts) {
3833
+ const existing = findWorkspaceByConfigPath(root, opts.configPath);
3834
+ if (existing) {
3835
+ const reg = readRegistry(root);
3836
+ const rec = reg.workspaces.find((w) => w.id === existing.id);
3837
+ if (rec) {
3838
+ rec.db = opts.db;
3839
+ rec.kind = "cloud";
3840
+ if (opts.makeActive) reg.activeWorkspaceId = rec.id;
3841
+ writeRegistry(root, reg);
3842
+ return rec;
3843
+ }
3844
+ }
3845
+ return addAdoptedWorkspace(root, {
3846
+ displayName: opts.displayName,
3847
+ db: opts.db,
3848
+ configPath: opts.configPath,
3849
+ contextDir: opts.contextDir,
3850
+ makeActive: opts.makeActive ?? false
3851
+ });
3852
+ }
3853
+ function removeWorkspace(root, id) {
3854
+ const reg = readRegistry(root);
3855
+ const idx = reg.workspaces.findIndex((w) => w.id === id);
3856
+ if (idx < 0) return null;
3857
+ const [removed] = reg.workspaces.splice(idx, 1);
3858
+ if (reg.activeWorkspaceId === id) {
3859
+ reg.activeWorkspaceId = reg.workspaces[0]?.id ?? null;
3860
+ }
3861
+ writeRegistry(root, reg);
3862
+ return removed ?? null;
3863
+ }
3864
+ function removeWorkspaceByConfigPath(root, configPath) {
3865
+ const match = findWorkspaceByConfigPath(root, configPath);
3866
+ return match ? removeWorkspace(root, match.id) : null;
3867
+ }
3868
+ function renameWorkspaceByConfigPath(root, configPath, displayName) {
3869
+ const match = findWorkspaceByConfigPath(root, configPath);
3870
+ if (!match) return;
3871
+ const reg = readRegistry(root);
3872
+ const rec = reg.workspaces.find((w) => w.id === match.id);
3873
+ if (rec) {
3874
+ rec.displayName = displayName;
3875
+ writeRegistry(root, reg);
3876
+ }
3877
+ }
3789
3878
 
3790
3879
  // src/framework/canonical-context.ts
3791
3880
  function deriveCanonicalContexts(tables) {
@@ -5891,14 +5980,15 @@ async function checkForUpdate(pkgName, currentVersion) {
5891
5980
  import { createServer } from "http";
5892
5981
  import { spawn as spawn2 } from "child_process";
5893
5982
  import {
5894
- existsSync as existsSync18,
5895
- mkdirSync as mkdirSync8,
5896
- readFileSync as readFileSync13,
5897
- readdirSync as readdirSync7,
5983
+ existsSync as existsSync20,
5984
+ mkdirSync as mkdirSync9,
5985
+ readFileSync as readFileSync14,
5986
+ readdirSync as readdirSync8,
5987
+ rmSync as rmSync2,
5898
5988
  unlinkSync as unlinkSync5,
5899
5989
  writeFileSync as writeFileSync8
5900
5990
  } from "fs";
5901
- import { basename as basename6, dirname as dirname8, join as join16, resolve as resolve6, sep as sep4 } from "path";
5991
+ import { basename as basename7, dirname as dirname11, join as join18, resolve as resolve8, sep as sep4 } from "path";
5902
5992
  import { parseDocument as parseDocument2 } from "yaml";
5903
5993
 
5904
5994
  // src/gui/http.ts
@@ -5912,7 +6002,7 @@ function sendJson(res, body, status = 200) {
5912
6002
  var DEFAULT_BODY_MAX_BYTES = 1e6;
5913
6003
  function readJson(req, opts = {}) {
5914
6004
  const maxBytes = opts.maxBytes ?? DEFAULT_BODY_MAX_BYTES;
5915
- return new Promise((resolve10, reject) => {
6005
+ return new Promise((resolve11, reject) => {
5916
6006
  let raw = "";
5917
6007
  req.setEncoding("utf8");
5918
6008
  req.on("data", (chunk) => {
@@ -5921,7 +6011,7 @@ function readJson(req, opts = {}) {
5921
6011
  });
5922
6012
  req.on("end", () => {
5923
6013
  try {
5924
- resolve10(raw ? JSON.parse(raw) : {});
6014
+ resolve11(raw ? JSON.parse(raw) : {});
5925
6015
  } catch {
5926
6016
  reject(new Error("Invalid JSON body"));
5927
6017
  }
@@ -5939,7 +6029,7 @@ async function tryHandler(res, fn) {
5939
6029
 
5940
6030
  // src/gui/data.ts
5941
6031
  import { existsSync as existsSync14, readFileSync as readFileSync10 } from "fs";
5942
- import { basename as basename3, join as join13, relative, resolve as resolve4, sep as sep2 } from "path";
6032
+ import { basename as basename3, join as join13, relative, resolve as resolve5, sep as sep2 } from "path";
5943
6033
  function tableToSummary(name, definition) {
5944
6034
  return {
5945
6035
  name,
@@ -5951,8 +6041,8 @@ function tableToSummary(name, definition) {
5951
6041
  };
5952
6042
  }
5953
6043
  function safeResolveInside(baseDir, requestedPath) {
5954
- const resolvedBase = resolve4(baseDir);
5955
- const resolved = resolve4(baseDir, requestedPath);
6044
+ const resolvedBase = resolve5(baseDir);
6045
+ const resolved = resolve5(baseDir, requestedPath);
5956
6046
  if (resolved !== resolvedBase && !resolved.startsWith(resolvedBase + sep2)) {
5957
6047
  throw new Error(`Path escapes output directory: ${requestedPath}`);
5958
6048
  }
@@ -6001,8 +6091,8 @@ function loadGuiData(configPath, outputDir) {
6001
6091
  tables,
6002
6092
  entities,
6003
6093
  project: {
6004
- configPath: resolve4(configPath),
6005
- outputDir: resolve4(outputDir),
6094
+ configPath: resolve5(configPath),
6095
+ outputDir: resolve5(outputDir),
6006
6096
  dbName: basename3(parsed.dbPath),
6007
6097
  tableCount: tables.length,
6008
6098
  entityContextCount: parsed.entityContexts.length,
@@ -6168,7 +6258,7 @@ function buildGuiGraph(configPath, outputDir, options = {}) {
6168
6258
  let relTarget;
6169
6259
  try {
6170
6260
  relTarget = relative(
6171
- resolve4(outputDir),
6261
+ resolve5(outputDir),
6172
6262
  safeResolveInside(outputDir, join13(fileDir, href))
6173
6263
  ).split(sep2).join("/");
6174
6264
  } catch {
@@ -6643,6 +6733,10 @@ var css = `
6643
6733
  .dm-graph .gnode-label { fill: var(--text); font-size: 12px; font-weight: 500; }
6644
6734
  .dm-graph .gnode-icon { dominant-baseline: middle; }
6645
6735
  .dm-graph .gnode:hover .gnode-dot { stroke: var(--text-muted); }
6736
+ /* Share-status stroke (cloud workspaces only): yellow = shared, red = private. */
6737
+ .dm-graph .gnode-shared .gnode-dot { stroke: #eab308; stroke-width: 2; }
6738
+ .dm-graph .gnode-private .gnode-dot { stroke: #ef4444; stroke-width: 2; }
6739
+ /* Selected (green) wins over share status \u2014 higher specificity (.gnode.active). */
6646
6740
  .dm-graph .gnode.active .gnode-dot { stroke: var(--accent); stroke-width: 2; }
6647
6741
  .dm-graph .gnode.active .gnode-glow { opacity: 0.18; }
6648
6742
  .dm-graph .gnode.active .gnode-label { fill: var(--accent); }
@@ -6656,6 +6750,11 @@ var css = `
6656
6750
  .dm-legend span { display: inline-flex; align-items: center; gap: 6px; }
6657
6751
  .dm-legend i { width: 16px; height: 0; border-top: 2px solid currentColor; display: inline-block; }
6658
6752
  .dm-legend i.dash { border-top-style: dashed; }
6753
+ /* Share-status swatches: filled dots rather than the relationship line. */
6754
+ .dm-legend i.sw { width: 10px; height: 10px; border-top: 0; border-radius: 50%; }
6755
+ .dm-legend i.sw-shared { background: #eab308; }
6756
+ .dm-legend i.sw-private { background: #ef4444; }
6757
+ .dm-legend i.sw-selected { background: var(--accent); }
6659
6758
  #dm-panel {
6660
6759
  background: var(--surface); border: 1px solid var(--border);
6661
6760
  border-radius: 10px; padding: 20px;
@@ -6954,6 +7053,17 @@ var css = `
6954
7053
  }
6955
7054
  .shared-row:hover, .member-row:hover { background: var(--row-hover); }
6956
7055
  .shared-row .table-name { font-family: ui-monospace, monospace; }
7056
+ /* Role/status pills inside the settings-drawer member list, which is not
7057
+ under .team-card \u2014 so the .team-card-scoped .role-tag rules don't reach
7058
+ it. Covers creator / member / and the pending-invitee invited/expired. */
7059
+ .members-list .role-tag {
7060
+ display: inline-block; padding: 2px 8px; border-radius: 4px;
7061
+ font-size: 11px; font-weight: 600; text-transform: uppercase;
7062
+ background: var(--accent-soft); color: var(--accent);
7063
+ }
7064
+ .members-list .role-tag.role-member { background: #eef0f3; color: var(--text-muted); }
7065
+ .members-list .role-tag.role-expired { background: #fde2e1; color: #b91c1c; }
7066
+ .member-row-pending { opacity: 0.85; }
6957
7067
  .teams-empty {
6958
7068
  padding: 32px; text-align: center; color: var(--text-muted);
6959
7069
  border: 1px dashed var(--border-strong); border-radius: 8px;
@@ -7089,6 +7199,8 @@ var css = `
7089
7199
  .fs-context-doc .md-body a { color: var(--accent); }
7090
7200
  .fs-field { padding: 12px 0; border-bottom: 1px solid var(--border); }
7091
7201
  .fs-field:last-child { border-bottom: none; }
7202
+ /* Inline create-view action row (Save / Cancel). */
7203
+ .fs-create-actions { display: flex; gap: 8px; justify-content: flex-end; max-width: 900px; margin-top: 16px; }
7092
7204
  .fs-field-label {
7093
7205
  font-size: 11px; color: var(--text-muted); text-transform: uppercase;
7094
7206
  letter-spacing: 0.04em; margin-bottom: 4px;
@@ -7330,7 +7442,6 @@ var appJs = `
7330
7442
  Promise.all([
7331
7443
  fetchJson('/api/entities'),
7332
7444
  fetchJson('/api/gui-meta').catch(function () { return {}; }),
7333
- fetchJson('/api/databases').catch(function () { return null; }),
7334
7445
  fetchJson('/api/gui-meta/columns').catch(function () { return {}; }),
7335
7446
  fetchJson('/api/system-tables').catch(function () { return { tables: [] }; }),
7336
7447
  fetchJson('/api/userconfig/preferences').catch(function () { return { show_system_tables: false, analytics: true }; }),
@@ -7338,15 +7449,14 @@ var appJs = `
7338
7449
  ]).then(function (results) {
7339
7450
  state.entities = results[0];
7340
7451
  state.iconOverrides = results[1] || {};
7341
- state.columnMeta = results[3] || {};
7342
- state.systemTables = (results[4] && results[4].tables) || [];
7343
- state.preferences = results[5] || { show_system_tables: false, analytics: true };
7452
+ state.columnMeta = results[2] || {};
7453
+ state.systemTables = (results[3] && results[3].tables) || [];
7454
+ state.preferences = results[4] || { show_system_tables: false, analytics: true };
7344
7455
  document.body.classList.toggle('advanced-mode', advancedMode());
7345
7456
  var advToggle = document.getElementById('advanced-toggle');
7346
7457
  if (advToggle) advToggle.checked = advancedMode();
7347
7458
  wireSettingsDrawer();
7348
- renderDbSwitcher(results[2]);
7349
- renderWsSwitcher(results[6]);
7459
+ renderWsSwitcher(results[5]);
7350
7460
  renderSidebar();
7351
7461
  wireHistoryControls();
7352
7462
  refreshHistoryState();
@@ -7649,9 +7759,8 @@ var appJs = `
7649
7759
  cloudMode = mode === 'cloud';
7650
7760
  cloudConnected = cloudMode && state === 'connected';
7651
7761
  if (cloudConnected && !wasConnected) drainQueue();
7652
- // Update both the database-switcher dot and the workspace-switcher dot so
7653
- // whichever switcher is visible reflects the live realtime status.
7654
- ['db-status', 'ws-status'].forEach(function (id) {
7762
+ // Update the single workspace-switcher status dot to reflect live realtime.
7763
+ ['ws-status'].forEach(function (id) {
7655
7764
  var el = document.getElementById(id);
7656
7765
  if (!el) return;
7657
7766
  el.classList.remove('is-cloud-connected', 'is-cloud-disconnected', 'is-cloud-connecting');
@@ -7943,17 +8052,15 @@ var appJs = `
7943
8052
  return Promise.all([
7944
8053
  fetchJson('/api/entities'),
7945
8054
  fetchJson('/api/gui-meta').catch(function () { return {}; }),
7946
- fetchJson('/api/databases').catch(function () { return null; }),
7947
8055
  fetchJson('/api/gui-meta/columns').catch(function () { return {}; }),
7948
8056
  fetchJson('/api/system-tables').catch(function () { return { tables: [] }; }),
7949
8057
  fetchJson('/api/workspaces').catch(function () { return null; }),
7950
8058
  ]).then(function (results) {
7951
8059
  state.entities = results[0];
7952
8060
  state.iconOverrides = results[1] || {};
7953
- state.columnMeta = results[3] || {};
7954
- state.systemTables = (results[4] && results[4].tables) || [];
7955
- renderDbSwitcher(results[2]);
7956
- renderWsSwitcher(results[5]);
8061
+ state.columnMeta = results[2] || {};
8062
+ state.systemTables = (results[3] && results[3].tables) || [];
8063
+ renderWsSwitcher(results[4]);
7957
8064
  renderSidebar();
7958
8065
  if (location.hash !== '#/') location.hash = '#/';
7959
8066
  else renderRoute();
@@ -7968,44 +8075,44 @@ var appJs = `
7968
8075
  var btn = document.getElementById('ws-button');
7969
8076
  var menu = document.getElementById('ws-menu');
7970
8077
  var nameEl = document.getElementById('ws-name');
7971
- var dbHost = document.getElementById('db-switcher-host');
7972
8078
  if (!wrap || !btn || !menu || !nameEl) return;
7973
- var list = (data && data.workspaces) || [];
7974
- // In workspace mode (a .lattice root with \u22651 workspace) the Workspaces
7975
- // switcher is the SINGLE switcher \u2014 the per-config database switcher is
7976
- // redundant there, so hide it. Without a root there are no workspaces, so
7977
- // the database switcher remains the fallback.
7978
- if (list.length < 1) {
7979
- wrap.hidden = true;
7980
- if (dbHost) dbHost.hidden = false;
7981
- return;
7982
- }
8079
+ // The workspace switcher is the SINGLE switcher: every database \u2014 local or
8080
+ // cloud, created or joined \u2014 is a workspace under the .lattice root, and
8081
+ // the GUI always has a root (see ensureRootForGui). No database mode.
7983
8082
  wrap.hidden = false;
7984
- if (dbHost) dbHost.hidden = true;
8083
+ var list = (data && data.workspaces) || [];
7985
8084
  var current = list.filter(function (w) { return w.id === (data && data.current); })[0];
7986
8085
  nameEl.textContent = (current && current.label) || 'workspace';
7987
8086
  var curKind = (current && current.kind) || 'local';
7988
8087
  setStatusPill(curKind, curKind === 'cloud' ? 'connecting' : 'local');
7989
8088
 
7990
8089
  function buildMenu() {
8090
+ var currentId = data && data.current;
7991
8091
  var items = list.map(function (w) {
7992
- var isCurrent = w.id === (data && data.current);
8092
+ var isCurrent = w.id === currentId;
8093
+ var isCloud = w.kind === 'cloud';
8094
+ var dotClass = isCloud ? 'is-cloud-connected' : '';
8095
+ var chipText = isCloud ? 'Cloud' : 'Local';
8096
+ var chipBg = isCloud ? 'var(--accent-soft)' : 'rgba(255,255,255,0.06)';
8097
+ var chipColor = isCloud ? 'var(--accent)' : 'var(--text-muted)';
7993
8098
  return '<button class="db-item' + (isCurrent ? ' active' : '') +
7994
8099
  '" data-id="' + escapeHtml(w.id) + '">' +
8100
+ '<span class="db-item-status db-status ' + dotClass + '" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' +
8101
+ (isCloud ? 'var(--accent)' : 'var(--warn)') +
8102
+ ';flex-shrink:0"></span>' +
7995
8103
  '<span style="flex:1;text-align:left">' + escapeHtml(w.label) + '</span>' +
7996
- '<span style="font-size:10px;padding:1px 6px;border-radius:8px;background:rgba(255,255,255,0.06);color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em">' + escapeHtml(w.kind) + '</span>' +
8104
+ '<span style="font-size:10px;padding:1px 6px;border-radius:8px;background:' + chipBg + ';color:' + chipColor + ';text-transform:uppercase;letter-spacing:0.04em">' + chipText + '</span>' +
7997
8105
  '</button>';
7998
8106
  }).join('');
7999
8107
  menu.innerHTML =
8000
8108
  '<div class="db-section">Workspaces</div>' + items +
8001
- '<div class="db-section">New workspace</div>' +
8002
8109
  '<div class="db-create">' +
8003
8110
  '<button class="btn primary" id="ws-create-btn" style="width:100%;">+ New workspace\u2026</button>' +
8004
8111
  '</div>';
8005
8112
  menu.querySelectorAll('button.db-item').forEach(function (b) {
8006
8113
  b.addEventListener('click', function () {
8007
8114
  var id = b.getAttribute('data-id');
8008
- if (id === (data && data.current)) { menu.hidden = true; return; }
8115
+ if (id === currentId) { menu.hidden = true; return; }
8009
8116
  withBusy(b, function () {
8010
8117
  return fetchJson('/api/workspaces/switch', {
8011
8118
  method: 'POST',
@@ -8020,13 +8127,11 @@ var appJs = `
8020
8127
  });
8021
8128
  });
8022
8129
  });
8023
- document.getElementById('ws-create-btn').addEventListener('click', function (e) {
8024
- // Stop propagation: showCreateWorkspaceInput replaces .db-create's
8025
- // innerHTML, detaching THIS button. Without this, the click then
8026
- // bubbles to the document outside-click closer, whose
8027
- // menu.contains(e.target) is now false (target detached) \u2192 it would
8028
- // close the menu, so the create input never appears.
8029
- e.stopPropagation(); showCreateWorkspaceInput(menu);
8130
+ // Create + Join both live in the 3-step wizard (local / cloud / join) \u2014
8131
+ // the single entry point for adding any workspace.
8132
+ document.getElementById('ws-create-btn').addEventListener('click', function () {
8133
+ menu.hidden = true;
8134
+ showCreateDatabaseWizard();
8030
8135
  });
8031
8136
  }
8032
8137
 
@@ -8051,125 +8156,6 @@ var appJs = `
8051
8156
  }
8052
8157
  }
8053
8158
 
8054
- // Inline "new workspace" name entry, shown inside the Workspaces menu.
8055
- function showCreateWorkspaceInput(menu) {
8056
- var host = menu.querySelector('.db-create');
8057
- if (!host) return;
8058
- host.innerHTML =
8059
- '<input id="ws-new-name" type="text" placeholder="Workspace name" autocomplete="off" ' +
8060
- 'style="width:100%;box-sizing:border-box;padding:7px 10px;margin-bottom:6px;' +
8061
- 'background:var(--surface-2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px" />' +
8062
- '<button class="btn primary" id="ws-new-create" style="width:100%;">Create</button>';
8063
- var input = document.getElementById('ws-new-name');
8064
- var create = document.getElementById('ws-new-create');
8065
- input.focus();
8066
- function submit() {
8067
- var name = (input.value || '').trim();
8068
- if (!name) { input.focus(); return; }
8069
- withBusy(create, function () {
8070
- return fetchJson('/api/workspaces/create', {
8071
- method: 'POST',
8072
- headers: { 'content-type': 'application/json' },
8073
- body: JSON.stringify({ name: name }),
8074
- }).then(function () {
8075
- menu.hidden = true;
8076
- return reloadEverything();
8077
- }).then(function () {
8078
- showToast('Created workspace', {});
8079
- }).catch(function (err) { showToast('Create failed: ' + err.message, {}); });
8080
- });
8081
- }
8082
- create.addEventListener('click', submit);
8083
- input.addEventListener('click', function (e) { e.stopPropagation(); });
8084
- input.addEventListener('keydown', function (e) {
8085
- if (e.key === 'Enter') { e.preventDefault(); submit(); }
8086
- else if (e.key === 'Escape') { menu.hidden = true; }
8087
- });
8088
- }
8089
-
8090
- function renderDbSwitcher(data) {
8091
- var btn = document.getElementById('db-button');
8092
- var menu = document.getElementById('db-menu');
8093
- var nameEl = document.getElementById('db-name');
8094
- if (!data) {
8095
- nameEl.textContent = '(no databases endpoint)';
8096
- return;
8097
- }
8098
- // Friendly DB name: prefer current.label (cloud team_name or YAML name:),
8099
- // fall back to the db file basename.
8100
- nameEl.textContent = (data.current && data.current.label) || data.current.dbFile || '';
8101
- // Initial status pill \u2014 overridden when the realtime SSE 'state'
8102
- // event arrives, but avoids a yellow flash before SSE connects.
8103
- var initialKind = (data.current && data.current.kind) || 'local';
8104
- setStatusPill(initialKind, initialKind === 'cloud' ? 'connecting' : 'local');
8105
-
8106
- function buildMenu() {
8107
- var currentPath = data.current && data.current.path;
8108
- var currentKind = (data.current && data.current.kind) || 'local';
8109
- var items = data.configs.map(function (c) {
8110
- // Per-row kind comes from the server now (each config resolves
8111
- // its db: line to postgres \u2192 cloud, else local), so inactive
8112
- // cloud rows tag Cloud/green just like the selected one \u2014 no
8113
- // more defaulting every non-active row to Local/yellow.
8114
- var kind = c.kind || (c.path === currentPath ? currentKind : 'local');
8115
- var isCloud = kind === 'cloud';
8116
- var dotClass = isCloud ? 'is-cloud-connected' : '';
8117
- var chipText = isCloud ? 'Cloud' : 'Local';
8118
- var chipBg = isCloud ? 'var(--accent-soft)' : 'rgba(255,255,255,0.06)';
8119
- var chipColor = isCloud ? 'var(--accent)' : 'var(--text-muted)';
8120
- return '<button class="db-item' + (c.active ? ' active' : '') +
8121
- '" data-path="' + escapeHtml(c.path) + '">' +
8122
- '<span class="db-item-status db-status ' + dotClass + '" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' +
8123
- (isCloud ? 'var(--accent)' : 'var(--warn)') +
8124
- ';flex-shrink:0"></span>' +
8125
- '<span style="flex:1;text-align:left">' + escapeHtml(c.label || c.name) + '</span>' +
8126
- '<span style="font-size:10px;padding:1px 6px;border-radius:8px;background:' + chipBg + ';color:' + chipColor + ';text-transform:uppercase;letter-spacing:0.04em">' + chipText + '</span>' +
8127
- '</button>';
8128
- }).join('');
8129
- menu.innerHTML =
8130
- '<div class="db-section">Available databases</div>' +
8131
- items +
8132
- '<div class="db-section">New database</div>' +
8133
- '<div class="db-create">' +
8134
- '<button class="btn primary" id="db-create-btn" style="width:100%;">+ New database\u2026</button>' +
8135
- '</div>';
8136
- menu.querySelectorAll('button.db-item').forEach(function (b) {
8137
- b.addEventListener('click', function () {
8138
- var path = b.getAttribute('data-path');
8139
- if (path === currentPath) { menu.hidden = true; return; }
8140
- withBusy(b, function () {
8141
- return fetchJson('/api/databases/switch', {
8142
- method: 'POST',
8143
- headers: { 'content-type': 'application/json' },
8144
- body: JSON.stringify({ path: path }),
8145
- }).then(function () {
8146
- menu.hidden = true;
8147
- return reloadEverything();
8148
- }).then(function () {
8149
- showToast('Switched database', {});
8150
- }).catch(function (err) { showToast('Switch failed: ' + err.message, {}); });
8151
- });
8152
- });
8153
- });
8154
- document.getElementById('db-create-btn').addEventListener('click', function () {
8155
- menu.hidden = true;
8156
- showCreateDatabaseWizard();
8157
- });
8158
- }
8159
-
8160
- btn.onclick = function (e) {
8161
- e.stopPropagation();
8162
- if (menu.hidden) buildMenu();
8163
- menu.hidden = !menu.hidden;
8164
- };
8165
- document.addEventListener('click', function (e) {
8166
- if (menu.hidden) return;
8167
- if (!menu.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
8168
- menu.hidden = true;
8169
- }
8170
- });
8171
- }
8172
-
8173
8159
  /** Reload icon overrides after a save, then re-render the current view. */
8174
8160
  function refreshIcons() {
8175
8161
  return fetchJson('/api/gui-meta').then(function (data) {
@@ -8239,7 +8225,10 @@ var appJs = `
8239
8225
  // Even segment count \u2192 item view; odd \u2192 folder/collection view.
8240
8226
  var fsegs = fsParse(hash);
8241
8227
  if (fsegs) {
8242
- if (fsegs.length % 2 === 1) renderFsCollection(content, fsegs);
8228
+ // #/fs/<table>/new \u2192 inline create view (must precede the even/odd
8229
+ // item-vs-collection heuristic, since [table,'new'] is even-length).
8230
+ if (fsegs[fsegs.length - 1] === 'new') renderFsCreate(content, fsegs);
8231
+ else if (fsegs.length % 2 === 1) renderFsCollection(content, fsegs);
8243
8232
  else renderFsItem(content, fsegs);
8244
8233
  return;
8245
8234
  }
@@ -8300,10 +8289,14 @@ var appJs = `
8300
8289
  return dashboardPreferenceRank(a.name) - dashboardPreferenceRank(b.name);
8301
8290
  });
8302
8291
  if (ents.length === 0) {
8292
+ // Generic, role-agnostic empty state \u2014 the old copy told everyone to
8293
+ // "edit lattice.config.yml / db.define()", which a joined cloud member
8294
+ // cannot act on (they just have nothing shared with them yet).
8303
8295
  content.innerHTML =
8304
8296
  '<div class="placeholder">' +
8305
- '<h2>No entities yet</h2>' +
8306
- '<p>Define entities in your <code>lattice.config.yml</code> or register them via <code>db.define()</code>, then reload.</p>' +
8297
+ '<h2>This workspace is empty</h2>' +
8298
+ '<p>There are no tables to show yet. Create one in the Data Model editor, ' +
8299
+ 'or \u2014 on a cloud workspace \u2014 ask the owner to share a table with you.</p>' +
8307
8300
  '</div>';
8308
8301
  return;
8309
8302
  }
@@ -8479,8 +8472,13 @@ var appJs = `
8479
8472
  return '<input type="password" name="' + escapeHtml(col) + '" value="' +
8480
8473
  escapeHtml(value || '') + '" autocomplete="off" />';
8481
8474
  }
8482
- // Multiline for known long-form fields.
8483
- if (col === 'transcript' || col === 'summary' || col === 'body') {
8475
+ // Multiline for ALL long-form fields (matches FS_LONGFORM, the same set
8476
+ // fsValInner renders as markdown) AND any value that already contains a
8477
+ // newline. A single-line <input> normalizes/strips newlines, so a
8478
+ // multi-line markdown value put in one would be silently corrupted on the
8479
+ // next blur (a spurious PATCH) and then re-rendered as mangled markdown
8480
+ // ("huge text"). A <textarea> round-trips the exact text.
8481
+ if (FS_LONGFORM.indexOf(col) >= 0 || (value != null && String(value).indexOf('\\n') >= 0)) {
8484
8482
  return '<textarea name="' + escapeHtml(col) + '">' + escapeHtml(value || '') + '</textarea>';
8485
8483
  }
8486
8484
  return '<input type="text" name="' + escapeHtml(col) + '" value="' + escapeHtml(value || '') + '" />';
@@ -9214,7 +9212,7 @@ var appJs = `
9214
9212
  // opens a create form. Related-row folders aren't a place to mint a
9215
9213
  // brand-new object, so the tile is top-level only.
9216
9214
  var createTile = topLevel
9217
- ? '<a class="fs-tile fs-tile-create" href="#" data-fs-create="1" title="Create a new ' + escapeHtml(d.label) + '">' +
9215
+ ? '<a class="fs-tile fs-tile-create" href="' + fsHref([table, 'new']) + '" title="Create a new ' + escapeHtml(d.label) + '">' +
9218
9216
  '<div class="fs-tile-icon">\u2795</div>' +
9219
9217
  '<div class="fs-tile-label">New ' + escapeHtml(d.label) + '</div>' +
9220
9218
  '</a>'
@@ -9236,11 +9234,6 @@ var appJs = `
9236
9234
  '<span class="count">' + rows.length + ' item' + (rows.length === 1 ? '' : 's') + '</span>' +
9237
9235
  '</div>' +
9238
9236
  '<div class="fs-grid">' + createTile + rowTiles + '</div>';
9239
- var ctile = content.querySelector('[data-fs-create]');
9240
- if (ctile) ctile.addEventListener('click', function (e) {
9241
- e.preventDefault();
9242
- openFsCreateModal(content, table, segs);
9243
- });
9244
9237
  });
9245
9238
  }).catch(function (err) {
9246
9239
  content.innerHTML = '<div class="placeholder"><h2>Failed</h2>' + escapeHtml(err.message) + '</div>';
@@ -9251,11 +9244,17 @@ var appJs = `
9251
9244
  // page with blank fields + a Save button, plus a select-menu + "+" for each
9252
9245
  // many-to-many link. Reuses fieldFor() (intrinsic + belongsTo) and the
9253
9246
  // existing row-create + junction-row endpoints (no new backend).
9254
- function openFsCreateModal(content, table, segs) {
9247
+ // Inline create view (#/fs/<table>/new) \u2014 mirrors renderFsItem's formatted
9248
+ // layout (.fs-doc/.fs-field) with blank fields + Save/Cancel, instead of a
9249
+ // modal. Reuses fieldFor() + the row-create + junction /link endpoints.
9250
+ function renderFsCreate(content, segs) {
9251
+ var table = segs[0];
9255
9252
  var t = tableByName(table);
9256
- if (!t) return;
9253
+ if (!t) { content.innerHTML = '<div class="placeholder">Unknown entity: ' + escapeHtml(table) + '</div>'; return; }
9254
+ var d = displayFor(table);
9257
9255
  var bt = belongsToColumns(t);
9258
9256
  var juncs = junctionsFor(table);
9257
+ var collectionHref = fsHref([table]);
9259
9258
  // Preload FK + junction-remote target rows so the <select> menus populate.
9260
9259
  var needed = bt.map(function (b) { return b.rel.table; })
9261
9260
  .concat(juncs.map(function (j) { return j.remoteRel.table; }));
@@ -9282,62 +9281,73 @@ var appJs = `
9282
9281
  '<button type="button" class="btn fs-link-add">+ Add another</button>' +
9283
9282
  '</div></div>';
9284
9283
  });
9285
- showModal('New ' + displayFor(table).label, '<div class="fs-doc fs-create-form">' + fieldsHtml + '</div>', {
9286
- primaryLabel: 'Save',
9287
- onBody: function (backdrop) {
9288
- backdrop.querySelectorAll('.fs-link-add').forEach(function (addBtn) {
9289
- addBtn.addEventListener('click', function () {
9290
- var stage = addBtn.previousElementSibling; // the .fs-link-stage
9291
- var firstSel = stage && stage.querySelector('.fs-link-select');
9292
- if (!firstSel) return;
9293
- var clone = firstSel.cloneNode(true);
9294
- clone.value = '';
9295
- stage.appendChild(clone);
9296
- });
9297
- });
9298
- },
9299
- onSubmit: function (scope) {
9300
- // Intrinsic + belongsTo values (the [name] inputs/selects).
9301
- var values = {};
9302
- scope.querySelectorAll('.fs-create-form [name]').forEach(function (el) {
9303
- var v = el.value;
9304
- if (v !== '' && v != null) values[el.getAttribute('name')] = v;
9305
- });
9306
- // Staged many-to-many links \u2014 one junction row per chosen target.
9307
- var links = [];
9308
- scope.querySelectorAll('.fs-link-stage').forEach(function (stage) {
9309
- var junction = stage.getAttribute('data-junction');
9310
- var localFk = stage.getAttribute('data-local-fk');
9311
- var remoteFk = stage.getAttribute('data-remote-fk');
9312
- stage.querySelectorAll('.fs-link-select').forEach(function (sel) {
9313
- if (sel.value) links.push({ junction: junction, localFk: localFk, remoteFk: remoteFk, remoteId: sel.value });
9314
- });
9284
+ content.innerHTML =
9285
+ '<nav class="fs-crumbs"><a href="#/">Home</a><span class="fs-sep">\u25B8</span>' +
9286
+ '<a href="' + collectionHref + '">' + escapeHtml(d.label) + '</a><span class="fs-sep">\u25B8</span>' +
9287
+ '<span>New</span></nav>' +
9288
+ '<div class="view-header">' +
9289
+ '<span class="entity-icon">' + d.icon + '</span>' +
9290
+ '<h1>New ' + escapeHtml(d.label) + '</h1>' +
9291
+ '</div>' +
9292
+ '<div class="fs-doc fs-create-form">' + fieldsHtml + '</div>' +
9293
+ '<div class="fs-create-actions">' +
9294
+ '<button class="btn" id="fs-create-cancel">Cancel</button>' +
9295
+ '<button class="btn primary" id="fs-create-save">Save</button>' +
9296
+ '</div>';
9297
+ content.querySelectorAll('.fs-link-add').forEach(function (addBtn) {
9298
+ addBtn.addEventListener('click', function () {
9299
+ var stage = addBtn.previousElementSibling; // the .fs-link-stage
9300
+ var firstSel = stage && stage.querySelector('.fs-link-select');
9301
+ if (!firstSel) return;
9302
+ var clone = firstSel.cloneNode(true);
9303
+ clone.value = '';
9304
+ stage.appendChild(clone);
9305
+ });
9306
+ });
9307
+ content.querySelector('#fs-create-cancel').addEventListener('click', function () {
9308
+ location.hash = collectionHref;
9309
+ });
9310
+ var saveBtn = content.querySelector('#fs-create-save');
9311
+ saveBtn.addEventListener('click', function () {
9312
+ var values = {};
9313
+ content.querySelectorAll('.fs-create-form [name]').forEach(function (el) {
9314
+ var v = el.value;
9315
+ if (v !== '' && v != null) values[el.getAttribute('name')] = v;
9316
+ });
9317
+ var links = [];
9318
+ content.querySelectorAll('.fs-link-stage').forEach(function (stage) {
9319
+ var junction = stage.getAttribute('data-junction');
9320
+ var localFk = stage.getAttribute('data-local-fk');
9321
+ var remoteFk = stage.getAttribute('data-remote-fk');
9322
+ stage.querySelectorAll('.fs-link-select').forEach(function (sel) {
9323
+ if (sel.value) links.push({ junction: junction, localFk: localFk, remoteFk: remoteFk, remoteId: sel.value });
9315
9324
  });
9325
+ });
9326
+ withBusy(saveBtn, function () {
9316
9327
  return rowWrite('POST', '/api/tables/' + encodeURIComponent(table) + '/rows', values).then(function (res) {
9317
9328
  var newId = res && (res.id || (res.row && res.row.id));
9318
9329
  var chain = Promise.resolve();
9319
9330
  links.forEach(function (lk) {
9320
9331
  chain = chain.then(function () {
9321
- // Use the junction's /link endpoint (INSERT OR IGNORE on the
9322
- // two FK columns) \u2014 works for junctions with no own pk and is
9323
- // idempotent, unlike a raw row insert.
9332
+ // Junction /link endpoint (INSERT OR IGNORE on the two FKs) \u2014
9333
+ // works for pk-less junctions + is idempotent.
9324
9334
  var jrow = {};
9325
9335
  jrow[lk.localFk] = newId;
9326
9336
  jrow[lk.remoteFk] = lk.remoteId;
9327
9337
  return rowWrite('POST', '/api/tables/' + encodeURIComponent(lk.junction) + '/link', jrow);
9328
9338
  });
9329
9339
  });
9330
- return chain;
9331
- }).then(function () {
9340
+ return chain.then(function () { return newId; });
9341
+ }).then(function (newId) {
9332
9342
  invalidate(table);
9333
- return refreshEntities();
9334
- }).then(function () {
9335
- showToast('Created', {});
9336
- renderFsCollection(content, segs);
9337
- });
9338
- },
9343
+ return refreshEntities().then(function () {
9344
+ showToast('Created', {});
9345
+ location.hash = newId ? fsHref([table, String(newId)]) : collectionHref;
9346
+ });
9347
+ }).catch(function (err) { showToast('Create failed: ' + err.message, {}); });
9348
+ });
9339
9349
  });
9340
- }).catch(function (err) { showToast('Create failed: ' + err.message, {}); });
9350
+ }).catch(function (err) { content.innerHTML = '<div class="placeholder"><h2>Failed</h2>' + escapeHtml(err.message) + '</div>'; });
9341
9351
  }
9342
9352
 
9343
9353
  // Item view \u2014 one row as a document (click-to-edit) + its relationship folders.
@@ -9871,6 +9881,11 @@ var appJs = `
9871
9881
  rowCount: rc,
9872
9882
  cols: (meta.columns || []).length,
9873
9883
  r: Math.max(11, Math.min(26, 11 + Math.sqrt(rc))),
9884
+ // Share status (cloud workspaces only). ownedByMe is set by the
9885
+ // server solely on cloud workspaces, so its presence flags a cloud
9886
+ // DB; on local DBs share status is N/A (no coloring).
9887
+ shared: meta.shared === true,
9888
+ cloudWorkspace: meta.ownedByMe !== undefined,
9874
9889
  x: 0, y: 0, vx: 0, vy: 0,
9875
9890
  });
9876
9891
  });
@@ -9961,7 +9976,11 @@ var appJs = `
9961
9976
  dash + markStart + markEnd + ' opacity="0.7"><title>' + escapeHtml(title) + '</title></line>';
9962
9977
  }).join('');
9963
9978
  var nodeSvg = nodes.map(function (nd) {
9964
- return '<g class="gnode" data-table="' + escapeHtml(nd.name) + '" transform="translate(' +
9979
+ // Share-status coloring applies only on cloud workspaces (G). On a
9980
+ // local DB share status is N/A, so no extra class \u2192 neutral stroke.
9981
+ var shareCls = nd.cloudWorkspace ? (nd.shared ? ' gnode-shared' : ' gnode-private') : '';
9982
+ var shareTitle = nd.cloudWorkspace ? ' \xB7 ' + (nd.shared ? 'shared' : 'private') : '';
9983
+ return '<g class="gnode' + shareCls + '" data-table="' + escapeHtml(nd.name) + '" transform="translate(' +
9965
9984
  nd.x.toFixed(1) + ',' + nd.y.toFixed(1) + ')">' +
9966
9985
  '<circle class="gnode-glow" r="' + (nd.r + 8).toFixed(1) + '"/>' +
9967
9986
  '<circle class="gnode-dot" r="' + nd.r.toFixed(1) + '"/>' +
@@ -9969,13 +9988,22 @@ var appJs = `
9969
9988
  (nd.r * 0.95).toFixed(1) + '">' + nd.icon + '</text>' +
9970
9989
  '<text class="gnode-label" y="' + (nd.r + 15).toFixed(1) + '" text-anchor="middle">' +
9971
9990
  escapeHtml(nd.label) + '</text>' +
9972
- '<title>' + escapeHtml(nd.label + ' \xB7 ' + nd.rowCount + ' rows \xB7 ' + nd.cols + ' columns') + '</title>' +
9991
+ '<title>' + escapeHtml(nd.label + ' \xB7 ' + nd.rowCount + ' rows \xB7 ' + nd.cols + ' columns' + shareTitle) + '</title>' +
9973
9992
  '</g>';
9974
9993
  }).join('');
9994
+ // Share legend entries only make sense on a cloud workspace (where nodes
9995
+ // carry share status). Local DBs show just the relationship key.
9996
+ var anyCloud = nodes.some(function (nd) { return nd.cloudWorkspace; });
9997
+ var shareLegend = anyCloud
9998
+ ? '<span><i class="sw sw-shared"></i><span style="color:var(--text-muted)">shared</span></span>' +
9999
+ '<span><i class="sw sw-private"></i><span style="color:var(--text-muted)">private</span></span>' +
10000
+ '<span><i class="sw sw-selected"></i><span style="color:var(--text-muted)">selected</span></span>'
10001
+ : '';
9975
10002
  var legend =
9976
10003
  '<div class="dm-legend">' +
9977
10004
  '<span style="color:' + DM_FK_COLOR + '"><i></i><span style="color:var(--text-muted)">foreign key</span></span>' +
9978
10005
  '<span style="color:' + DM_M2M_COLOR + '"><i class="dash"></i><span style="color:var(--text-muted)">many-to-many</span></span>' +
10006
+ shareLegend +
9979
10007
  '</div>';
9980
10008
  return '<svg class="dm-graph" viewBox="' + vb.join(' ') + '" preserveAspectRatio="xMidYMid meet">' +
9981
10009
  defs + '<g class="dm-stage">' + edgeSvg + nodeSvg + '</g></svg>' + legend;
@@ -10124,7 +10152,7 @@ var appJs = `
10124
10152
  '</div>' +
10125
10153
  '<div class="muted" style="margin-top:14px;font-size:12px;">' +
10126
10154
  'New entities get id (uuid PK), name, and deleted_at columns. ' +
10127
- 'Add more columns once the entity exists. On a team cloud the ' +
10155
+ 'Add more columns once the entity exists. On a cloud workspace the ' +
10128
10156
  'entity is private to you until you share it.' +
10129
10157
  '</div>';
10130
10158
  wireEmojiPicker(panel, 'dm-create-icon');
@@ -10269,20 +10297,20 @@ var appJs = `
10269
10297
  '</div>'
10270
10298
  : '<span class="muted" style="font-size:12px">No other entities to link to.</span>';
10271
10299
 
10272
- // Team-cloud sharing row \u2014 only the owner of a table may toggle
10273
- // its team visibility (t.ownedByMe is set by the server only for
10274
- // team clouds). Tables shared to me by others, and all non-team
10300
+ // Cloud sharing row \u2014 only the owner of a table may toggle its
10301
+ // visibility (t.ownedByMe is set by the server only for cloud
10302
+ // workspaces). Tables shared to me by others, and all local-DB
10275
10303
  // tables, show no sharing control.
10276
10304
  var canShare = !!(t && t.ownedByMe === true);
10277
10305
  var isShared = !!(t && t.shared);
10278
10306
  var shareRow = canShare
10279
- ? '<label>Team sharing</label>' +
10307
+ ? '<label>Cloud sharing</label>' +
10280
10308
  '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">' +
10281
10309
  '<button class="btn' + (isShared ? '' : ' primary') + '" id="dm-share-btn">' +
10282
- (isShared ? 'Unshare from team' : 'Share with team') +
10310
+ (isShared ? 'Make private' : 'Share with workspace') +
10283
10311
  '</button>' +
10284
10312
  '<span style="font-size:12px;color:var(--text-muted)">' +
10285
- (isShared ? 'Visible to every team member.' : 'Private to you. Share to make it visible to the team.') +
10313
+ (isShared ? 'Visible to everyone on this cloud workspace.' : 'Private to you. Share to make it visible to everyone on this cloud workspace.') +
10286
10314
  '</span>' +
10287
10315
  '</div>'
10288
10316
  : '';
@@ -10347,7 +10375,7 @@ var appJs = `
10347
10375
  // so a light in-place refresh reflects it without a full reload.
10348
10376
  return dmRefreshPanel(tableName, false);
10349
10377
  }).then(function () {
10350
- showToast(isShared ? 'Unshared "' + tableName + '" from team' : 'Shared "' + tableName + '" with team', {});
10378
+ showToast(isShared ? 'Unshared "' + tableName + '" from workspace' : 'Shared "' + tableName + '" with workspace', {});
10351
10379
  }).catch(function (e) { showToast('Share update failed: ' + e.message, {}); });
10352
10380
  });
10353
10381
  });
@@ -10739,7 +10767,7 @@ var appJs = `
10739
10767
  backdrop.className = 'modal-backdrop';
10740
10768
  backdrop.innerHTML =
10741
10769
  '<div class="modal" style="min-width:560px;max-width:640px">' +
10742
- '<div class="modal-head" id="wiz-head">New database \u2014 step 1 of 3</div>' +
10770
+ '<div class="modal-head" id="wiz-head">New workspace \u2014 step 1 of 3</div>' +
10743
10771
  '<div class="modal-body" id="wiz-body"></div>' +
10744
10772
  '<div class="modal-foot">' +
10745
10773
  '<button class="btn" data-act="cancel">Cancel</button>' +
@@ -10760,7 +10788,7 @@ var appJs = `
10760
10788
  var body = backdrop.querySelector('#wiz-body');
10761
10789
  var nextBtn = backdrop.querySelector('[data-act="next"]');
10762
10790
  var backBtn = backdrop.querySelector('[data-act="back"]');
10763
- head.textContent = 'New database \u2014 step ' + wizState.step + ' of 3';
10791
+ head.textContent = 'New workspace \u2014 step ' + wizState.step + ' of 3';
10764
10792
  backBtn.style.display = wizState.step === 1 ? 'none' : '';
10765
10793
  nextBtn.textContent = wizState.step === 3 ? 'Create' : 'Next';
10766
10794
  if (wizState.step === 1) body.innerHTML = renderStep1();
@@ -10774,7 +10802,7 @@ var appJs = `
10774
10802
  // Join uses the existing invite-redeem modal (opened on Next), so no
10775
10803
  // name/entities steps \u2014 the DB name comes from the team you join.
10776
10804
  var nameField = kind === 'join' ? '' :
10777
- '<div class="field"><label>Database name</label>' +
10805
+ '<div class="field"><label>Workspace name</label>' +
10778
10806
  '<input id="wiz-name" type="text" value="' + escapeHtml(wizState.name) +
10779
10807
  '" placeholder="e.g. my-research, design-system" maxlength="200" />' +
10780
10808
  '</div>';
@@ -10806,11 +10834,11 @@ var appJs = `
10806
10834
  '<input type="radio" name="wiz-kind" value="cloud"' + (kind === 'cloud' ? ' checked' : '') + ' /> New cloud (Postgres)' +
10807
10835
  '</label>' +
10808
10836
  '<label style="display:flex;align-items:center;gap:6px;font-weight:400;text-transform:none;letter-spacing:0">' +
10809
- '<input type="radio" name="wiz-kind" value="join"' + (kind === 'join' ? ' checked' : '') + ' /> Join existing cloud (invite)' +
10837
+ '<input type="radio" name="wiz-kind" value="join"' + (kind === 'join' ? ' checked' : '') + ' /> Join a team (invite)' +
10810
10838
  '</label>' +
10811
10839
  '</div>' +
10812
10840
  '<p style="font-size:11px;color:var(--text-muted);margin:6px 0 0">' +
10813
- 'Local databases are single-user SQLite files on your machine. Cloud databases are Postgres, can be shared with invited team members, and stream realtime updates. Joining connects to a cloud DB you were invited to.' +
10841
+ 'Local workspaces are single-user SQLite files on your machine. Cloud workspaces are Postgres, can be shared with invited members, and stream realtime updates. Join a team you were invited to with an invite token.' +
10814
10842
  '</p>' +
10815
10843
  '</div>' +
10816
10844
  cloudBlock;
@@ -10838,7 +10866,7 @@ var appJs = `
10838
10866
  '<button class="btn" id="wiz-add-entity" style="margin-top:10px">+ Add entity</button>' +
10839
10867
  (wizState.kind === 'cloud'
10840
10868
  ? '<p style="font-size:11px;color:var(--text-muted);margin:10px 0 0">' +
10841
- 'Entities with \u201CShare with cloud\u201D checked are visible to every team member. Unchecked entities live on the cloud DB but stay scoped to your own row links.' +
10869
+ 'Entities with \u201CShare with cloud\u201D checked are visible to everyone on the cloud workspace. Unchecked entities live on the cloud DB but stay scoped to your own row links.' +
10842
10870
  '</p>'
10843
10871
  : '');
10844
10872
  }
@@ -10915,17 +10943,17 @@ var appJs = `
10915
10943
 
10916
10944
  function goNext() {
10917
10945
  if (wizState.step === 1) {
10918
- // Join existing cloud: hand off to the invite-redeem modal, which
10919
- // collects the cloud URL + invite token and connects.
10946
+ // Join a team: hand off to the invite-redeem modal, which collects
10947
+ // the cloud URL + invite token and joins as a member.
10920
10948
  if (wizState.kind === 'join') { close(); showJoinTeamModal('project'); return; }
10921
- if (!wizState.name.trim()) { alert('Database name is required'); return; }
10949
+ if (!wizState.name.trim()) { alert('Workspace name is required'); return; }
10922
10950
  if (!/^[a-zA-Z0-9][a-zA-Z0-9 ._-]{0,199}$/.test(wizState.name.trim())) {
10923
- alert('Database name must start with a letter or digit and contain only letters, digits, spaces, dots, underscores, or hyphens'); return;
10951
+ alert('Workspace name must start with a letter or digit and contain only letters, digits, spaces, dots, underscores, or hyphens'); return;
10924
10952
  }
10925
10953
  if (wizState.kind === 'cloud') {
10926
10954
  if (!/^postgres(ql)?:\\/\\//i.test(wizState.cloudUrl.trim())) { alert('Cloud URL must start with postgres://'); return; }
10927
- if (!wizState.email.trim()) { alert('Email is required for cloud databases'); return; }
10928
- if (!wizState.displayName.trim()) { alert('Display name is required for cloud databases'); return; }
10955
+ if (!wizState.email.trim()) { alert('Email is required for cloud workspaces'); return; }
10956
+ if (!wizState.displayName.trim()) { alert('Display name is required for cloud workspaces'); return; }
10929
10957
  }
10930
10958
  wizState.step = 2;
10931
10959
  render();
@@ -10954,7 +10982,7 @@ var appJs = `
10954
10982
  close();
10955
10983
  return reloadEverything();
10956
10984
  }).then(function () {
10957
- showToast('Database "' + wizState.name + '" created', {});
10985
+ showToast('Workspace "' + wizState.name + '" created', {});
10958
10986
  }).catch(function (err) {
10959
10987
  nextBtn.removeAttribute('disabled');
10960
10988
  nextBtn.textContent = 'Create';
@@ -10963,29 +10991,19 @@ var appJs = `
10963
10991
  }
10964
10992
 
10965
10993
  function submitLocal() {
10966
- // Slug the name for the YAML filename; the friendly name goes
10967
- // into the new config's name: key via /api/dbconfig/rename
10968
- // after the create succeeds.
10969
- var slug = wizState.name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
10970
- return fetchJson('/api/databases/create', {
10994
+ // Create + activate a new local workspace in the registry (the single
10995
+ // source of truth). The friendly name is the workspace display name \u2014
10996
+ // no separate slug/config-file/rename dance.
10997
+ return fetchJson('/api/workspaces/create', {
10971
10998
  method: 'POST',
10972
10999
  headers: { 'content-type': 'application/json' },
10973
- body: JSON.stringify({ name: slug }),
10974
- }).then(function () {
10975
- // After the create, the active DB is the new one. Set the
10976
- // friendly name + add starter entities.
10977
- return fetchJson('/api/dbconfig/rename', {
10978
- method: 'POST',
10979
- headers: { 'content-type': 'application/json' },
10980
- body: JSON.stringify({ name: wizState.name.trim() }),
10981
- });
11000
+ body: JSON.stringify({ name: wizState.name.trim() }),
10982
11001
  }).then(function () {
10983
11002
  return createStarterEntities(wizState.entities);
10984
11003
  });
10985
11004
  }
10986
11005
 
10987
11006
  function submitCloud() {
10988
- var createdTeamId = null;
10989
11007
  return fetchJson('/api/teams-gui/connections/register-and-create', {
10990
11008
  method: 'POST',
10991
11009
  headers: { 'content-type': 'application/json' },
@@ -10996,8 +11014,20 @@ var appJs = `
10996
11014
  team_name: wizState.name.trim(),
10997
11015
  }),
10998
11016
  }).then(function (result) {
10999
- createdTeamId = result && result.team && result.team.id;
11000
- return createStarterEntities(wizState.entities, createdTeamId);
11017
+ var createdTeamId = result && result.team && result.team.id;
11018
+ var wsId = result && result.workspace_id;
11019
+ // Switch INTO the new cloud workspace so starter entities are
11020
+ // created there (not in the previously-active local workspace).
11021
+ var switched = wsId
11022
+ ? fetchJson('/api/workspaces/switch', {
11023
+ method: 'POST',
11024
+ headers: { 'content-type': 'application/json' },
11025
+ body: JSON.stringify({ id: wsId }),
11026
+ })
11027
+ : Promise.resolve();
11028
+ return switched.then(function () {
11029
+ return createStarterEntities(wizState.entities, createdTeamId);
11030
+ });
11001
11031
  });
11002
11032
  }
11003
11033
 
@@ -11073,7 +11103,7 @@ var appJs = `
11073
11103
  '<p style="font-size:12px;color:var(--text-muted);margin:0">' +
11074
11104
  'Use the same Postgres URL the inviter used (postgres://\u2026). Your email + display name come from User Settings \u2014 change them there. The email must match the address the invitation was addressed to.' +
11075
11105
  '</p>';
11076
- showModal('Join team', bodyHtml, {
11106
+ showModal('Join workspace', bodyHtml, {
11077
11107
  primaryLabel: 'Join',
11078
11108
  onSubmit: function (scope) {
11079
11109
  var data = collectFormValues(scope);
@@ -11082,18 +11112,20 @@ var appJs = `
11082
11112
  headers: { 'content-type': 'application/json' },
11083
11113
  body: JSON.stringify(data),
11084
11114
  }).then(function (res) {
11085
- // Auto-switch to the joined cloud DB so it shows in the
11086
- // header dropdown and becomes active immediately \u2014 no
11087
- // manual page refresh needed.
11088
- var path = res && res.config_path;
11089
- if (!path) { refreshSettingsRoute(kind); return; }
11090
- return fetchJson('/api/databases/switch', {
11115
+ // Auto-switch to the joined cloud workspace so it shows in the
11116
+ // header switcher and becomes active immediately \u2014 no manual
11117
+ // refresh. The join response carries the new workspace id.
11118
+ var wsId = res && res.workspace_id;
11119
+ if (!wsId) {
11120
+ return reloadEverything().then(function () { refreshSettingsRoute(kind); });
11121
+ }
11122
+ return fetchJson('/api/workspaces/switch', {
11091
11123
  method: 'POST',
11092
11124
  headers: { 'content-type': 'application/json' },
11093
- body: JSON.stringify({ path: path }),
11125
+ body: JSON.stringify({ id: wsId }),
11094
11126
  })
11095
11127
  .then(function () { return reloadEverything(); })
11096
- .then(function () { showToast('Joined "' + (res.team && res.team.name || 'team') + '" \u2014 switched to it', {}); });
11128
+ .then(function () { showToast('Joined "' + (res.team && res.team.name || 'workspace') + '" \u2014 switched to it', {}); });
11097
11129
  });
11098
11130
  },
11099
11131
  });
@@ -11156,7 +11188,7 @@ var appJs = `
11156
11188
  host.innerHTML =
11157
11189
  '<div class="dbconfig-panel" style="margin-bottom:18px;padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">' +
11158
11190
  '<h3 style="margin:0 0 10px">Identity</h3>' +
11159
- '<p class="lead" style="margin:0 0 10px">Display name + email used when creating or joining teams. Saved to ~/.lattice/identity.json and mirrored into the active Lattice.</p>' +
11191
+ '<p class="lead" style="margin:0 0 10px">Display name + email used when creating or joining cloud workspaces. Saved to ~/.lattice/identity.json and mirrored into the active Lattice.</p>' +
11160
11192
  '<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:8px">' +
11161
11193
  '<div><label class="field-label">Display name</label><input id="id-display-name" type="text" value="' + escapeHtml(id.display_name || '') + '" style="width:100%"></div>' +
11162
11194
  '<div><label class="field-label">Email</label><input id="id-email" type="email" value="' + escapeHtml(id.email || '') + '" style="width:100%"></div>' +
@@ -11183,65 +11215,6 @@ var appJs = `
11183
11215
  });
11184
11216
  }
11185
11217
 
11186
- function renderDatabasesPanel(host) {
11187
- fetchJson('/api/userconfig/databases').then(function (cat) {
11188
- var localRows = (cat.local || []).map(function (d) {
11189
- var stateBadge = '<span style="font-family:JetBrains Mono,monospace;font-size:10px;color:var(--text-muted)">' + escapeHtml((d.state || 'local').toUpperCase()) + '</span>';
11190
- return '<tr' + (d.active ? '' : ' class="db-row" data-switch-path="' + escapeHtml(d.configPath) + '"') + '>' +
11191
- '<td>' + escapeHtml(d.label) + (d.active ? ' <span class="role-tag">active</span>' : '') + '</td>' +
11192
- '<td>SQLite</td>' +
11193
- '<td>' + stateBadge + '</td>' +
11194
- '<td><code>' + escapeHtml(d.dbFile) + '</code></td>' +
11195
- '<td>\u2014</td>' +
11196
- '</tr>';
11197
- }).join('');
11198
- var cloudRows = (cat.cloud || []).map(function (d) {
11199
- var stateBadge = '<span style="font-family:JetBrains Mono,monospace;font-size:10px;color:var(--text-muted)">' + escapeHtml((d.state || 'unknown').toUpperCase()) + '</span>';
11200
- return '<tr><td>' + escapeHtml(d.label) + '</td><td>Postgres</td><td>' + stateBadge + '</td><td>(encrypted)</td><td>\u2014</td></tr>';
11201
- }).join('');
11202
- host.innerHTML =
11203
- '<div class="dbconfig-panel" style="margin-bottom:18px;padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">' +
11204
- '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">' +
11205
- '<h3 style="margin:0">Databases</h3>' +
11206
- '<button class="btn primary" id="action-add-cloud-db">Add a cloud DB \u2192</button>' +
11207
- '</div>' +
11208
- '<table style="width:100%;border-collapse:collapse">' +
11209
- '<thead><tr style="text-align:left"><th>Label</th><th>Type</th><th>State</th><th>File / source</th><th>Action</th></tr></thead>' +
11210
- '<tbody>' + (localRows + cloudRows || '<tr><td colspan="5" style="padding:8px;color:var(--text-muted)">No databases configured.</td></tr>') + '</tbody>' +
11211
- '</table>' +
11212
- '</div>';
11213
- host.querySelectorAll('tr.db-row[data-switch-path]').forEach(function (row) {
11214
- row.addEventListener('click', function () {
11215
- var configPath = row.getAttribute('data-switch-path');
11216
- fetch('/api/databases/switch', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: configPath }) })
11217
- .then(function (r) { return r.json(); })
11218
- .then(function () { renderUserConfig(document.getElementById('content')); });
11219
- });
11220
- });
11221
- var addCloudBtn = document.getElementById('action-add-cloud-db');
11222
- if (addCloudBtn) addCloudBtn.addEventListener('click', function () {
11223
- // Create a fresh project then immediately open the Connect-
11224
- // existing wizard against it. The backend's /api/databases/create
11225
- // makes a starter YAML + swaps the active Lattice to it; the
11226
- // wizard then rewrites that project's db: line.
11227
- var name = prompt('Project name for the new cloud-connected project:');
11228
- if (!name) return;
11229
- fetch('/api/databases/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: name }) })
11230
- .then(function (r) { return r.json(); })
11231
- .then(function (d) {
11232
- if (d.error) { alert('Failed: ' + d.error); return; }
11233
- // Active swapped to the new project \u2014 open Connect-existing.
11234
- showConnectExistingModal(function () {
11235
- renderUserConfig(document.getElementById('content'));
11236
- });
11237
- })
11238
- .catch(function (e) { alert('Failed: ' + e.message); });
11239
- });
11240
- }).catch(function (err) {
11241
- host.innerHTML = '<div class="placeholder">Failed to load databases: ' + escapeHtml(err.message) + '</div>';
11242
- });
11243
- }
11244
-
11245
11218
  function renderProjectConfig(content) {
11246
11219
  // Legacy entry \u2014 Track 4e renames this view to "Database Settings"
11247
11220
  // and adds an editable name header. The new alias is renderDatabaseSettings.
@@ -11258,8 +11231,8 @@ var appJs = `
11258
11231
  // Database panel below.
11259
11232
  content.innerHTML =
11260
11233
  '<div class="teams-page">' +
11261
- '<h2>Database Settings</h2>' +
11262
- '<div id="db-name-host"><div class="placeholder" style="padding:14px">Loading database name\u2026</div></div>' +
11234
+ '<h2>Workspace Settings</h2>' +
11235
+ '<div id="db-name-host"><div class="placeholder" style="padding:14px">Loading workspace name\u2026</div></div>' +
11263
11236
  '<div id="dbconfig-host"><div class="placeholder" style="padding:18px">Loading database configuration\u2026</div></div>' +
11264
11237
  '<div id="data-model-host"><div class="placeholder" style="padding:18px">Loading data model\u2026</div></div>' +
11265
11238
  '<div id="db-danger-host"></div>' +
@@ -11273,17 +11246,17 @@ var appJs = `
11273
11246
  // Confirmation modal for the irreversible delete. Gated on typing the exact
11274
11247
  // database name; the OK button is solid red (destructive) and disabled until
11275
11248
  // the name matches. onDone(result) runs after a successful delete.
11276
- function confirmDeleteDatabase(path, label, onDone) {
11277
- var safeLabel = (label || '').trim() || 'this database';
11249
+ function confirmDeleteDatabase(id, label, onDone) {
11250
+ var safeLabel = (label || '').trim() || 'this workspace';
11278
11251
  var body =
11279
11252
  '<p style="margin:0 0 10px">Permanently delete <strong>' + escapeHtml(safeLabel) + '</strong>? ' +
11280
- 'This removes its configuration and, for a local database, deletes the underlying SQLite file. ' +
11281
- 'For a cloud database only the local connection is forgotten \u2014 the remote data is left untouched. ' +
11253
+ 'This removes it from this lattice and, for a local workspace, deletes the underlying SQLite file. ' +
11254
+ 'For a cloud workspace only the local connection is forgotten \u2014 the remote data is left untouched. ' +
11282
11255
  '<strong style="color:var(--danger)">This cannot be undone.</strong></p>' +
11283
11256
  '<p style="margin:0 0 6px;font-size:12px;color:var(--text-muted)">Type <strong>' + escapeHtml(safeLabel) + '</strong> to confirm:</p>' +
11284
11257
  '<input id="confirm-db-name" type="text" autocomplete="off" style="width:100%" />';
11285
- showModal('Delete database', body, {
11286
- primaryLabel: 'Delete database',
11258
+ showModal('Delete workspace', body, {
11259
+ primaryLabel: 'Delete workspace',
11287
11260
  primaryClass: 'destructive',
11288
11261
  onBody: function (backdrop) {
11289
11262
  var input = backdrop.querySelector('#confirm-db-name');
@@ -11296,11 +11269,11 @@ var appJs = `
11296
11269
  },
11297
11270
  onSubmit: function (backdrop) {
11298
11271
  var v = (backdrop.querySelector('#confirm-db-name').value || '').trim();
11299
- if (v !== safeLabel) return Promise.reject(new Error('Type the database name exactly to confirm.'));
11300
- return fetch('/api/databases/delete', {
11272
+ if (v !== safeLabel) return Promise.reject(new Error('Type the workspace name exactly to confirm.'));
11273
+ return fetch('/api/workspaces/delete', {
11301
11274
  method: 'POST',
11302
11275
  headers: { 'content-type': 'application/json' },
11303
- body: JSON.stringify({ path: path }),
11276
+ body: JSON.stringify({ id: id }),
11304
11277
  })
11305
11278
  .then(function (r) { return r.json().then(function (d) { return { status: r.status, d: d }; }); })
11306
11279
  .then(function (res) {
@@ -11314,25 +11287,26 @@ var appJs = `
11314
11287
  function renderDatabaseDangerZone(host) {
11315
11288
  if (!host) return;
11316
11289
  Promise.all([
11317
- fetchJson('/api/databases'),
11290
+ fetchJson('/api/workspaces'),
11318
11291
  fetchJson('/api/dbconfig').catch(function () { return {}; }),
11319
11292
  ]).then(function (results) {
11320
11293
  var data = results[0];
11321
11294
  var cfg = results[1] || {};
11322
- var current = (data && data.current) || {};
11323
- var label = current.label || current.dbFile || '';
11324
- var path = current.path || '';
11325
- if (!path) { host.innerHTML = ''; return; }
11326
-
11327
- // After tearing down / leaving the active DB, switch to another the
11328
- // operator still has and navigate off the (now-gone) page.
11295
+ var currentId = (data && data.current) || null;
11296
+ var workspaces = (data && data.workspaces) || [];
11297
+ var current = workspaces.filter(function (w) { return w.id === currentId; })[0] || {};
11298
+ var label = current.label || '';
11299
+ var id = current.id || '';
11300
+ if (!id) { host.innerHTML = ''; return; }
11301
+
11302
+ // After tearing down / leaving the active workspace, switch to another
11303
+ // the operator still has and navigate off the (now-gone) page.
11329
11304
  var switchAway = function () {
11330
- var cur = (data && data.current && data.current.path) || null;
11331
- var target = ((data && data.configs) || []).filter(function (c) { return c.path !== cur; })[0];
11305
+ var target = workspaces.filter(function (w) { return w.id !== currentId; })[0];
11332
11306
  var p = target
11333
- ? fetchJson('/api/databases/switch', {
11307
+ ? fetchJson('/api/workspaces/switch', {
11334
11308
  method: 'POST', headers: { 'content-type': 'application/json' },
11335
- body: JSON.stringify({ path: target.path }),
11309
+ body: JSON.stringify({ id: target.id }),
11336
11310
  }).then(function () { return reloadEverything(); })
11337
11311
  : reloadEverything();
11338
11312
  return p.then(function () { location.hash = '#/'; renderRoute(); });
@@ -11344,7 +11318,7 @@ var appJs = `
11344
11318
  '<div class="danger-zone">' +
11345
11319
  '<h3>Danger zone</h3>' +
11346
11320
  '<p style="font-size:12px;color:var(--text-muted);margin:0 0 10px">' +
11347
- 'Disconnect this database from the cloud. This removes the team and <strong>kicks all members</strong>. This cannot be undone.' +
11321
+ 'Disconnect this database from the cloud. This dissolves the cloud workspace and <strong>kicks all members</strong>. This cannot be undone.' +
11348
11322
  '</p>' +
11349
11323
  '<button class="btn destructive" id="db-disconnect-btn">Disconnect from cloud</button>' +
11350
11324
  '</div>';
@@ -11365,35 +11339,41 @@ var appJs = `
11365
11339
  '<div class="danger-zone">' +
11366
11340
  '<h3>Danger zone</h3>' +
11367
11341
  '<p style="font-size:12px;color:var(--text-muted);margin:0 0 10px">' +
11368
- 'Leave this team. The cloud database keeps running for everyone else; you simply stop being a member.' +
11342
+ 'Leave this cloud workspace. It keeps running for everyone else; you simply stop being a member.' +
11369
11343
  '</p>' +
11370
- '<button class="btn destructive" id="db-leave-btn">Leave team</button>' +
11344
+ '<button class="btn destructive" id="db-leave-btn">Leave workspace</button>' +
11371
11345
  '</div>';
11372
11346
  host.querySelector('#db-leave-btn').addEventListener('click', function () {
11373
11347
  if (!confirm('Leave "' + (cfg.teamName || label || 'this team') + '"?')) return;
11374
11348
  var lbtn = host.querySelector('#db-leave-btn');
11375
11349
  withBusy(lbtn, function () {
11376
11350
  return fetchJson('/api/teams-gui/teams/' + cfg.teamId + '/members/' + encodeURIComponent(cfg.myUserId), { method: 'DELETE' })
11377
- .then(function () { showToast('Left the team', {}); return switchAway(); })
11351
+ .then(function () { showToast('Left the workspace', {}); return switchAway(); })
11378
11352
  .catch(function (e) { alert('Leave failed: ' + e.message); });
11379
11353
  });
11380
11354
  });
11381
11355
  return;
11382
11356
  }
11383
- // Local / non-team cloud database: delete it.
11357
+ // Local / non-team cloud workspace: delete it.
11384
11358
  host.innerHTML =
11385
11359
  '<div class="danger-zone">' +
11386
11360
  '<h3>Danger zone</h3>' +
11387
11361
  '<p style="font-size:12px;color:var(--text-muted);margin:0 0 10px">' +
11388
- 'Permanently delete this database. The configuration is removed and, for a local database, the underlying SQLite file is deleted. This cannot be undone.' +
11362
+ 'Permanently delete this workspace. It is removed from this lattice and, for a local workspace, the underlying SQLite file is deleted. This cannot be undone.' +
11389
11363
  '</p>' +
11390
- '<button class="btn destructive" id="db-delete-btn">Delete database</button>' +
11364
+ '<button class="btn destructive" id="db-delete-btn">Delete workspace</button>' +
11391
11365
  '</div>';
11392
11366
  host.querySelector('#db-delete-btn').addEventListener('click', function () {
11393
- confirmDeleteDatabase(path, label, function () {
11394
- // We just deleted the active DB; the server switched to a fallback.
11367
+ confirmDeleteDatabase(id, label, function () {
11368
+ // We just deleted the active workspace; the server switched to a
11369
+ // fallback. Re-render the drawer's Workspace-settings tab so it
11370
+ // reflects the NEW active workspace \u2014 previously this rendered into
11371
+ // #content behind the open drawer, leaving the user stuck on the
11372
+ // deleted workspace's settings.
11395
11373
  return reloadEverything().then(function () {
11396
- renderDatabaseSettings(document.getElementById('content'));
11374
+ var drawer = document.getElementById('settings-drawer');
11375
+ if (drawer && !drawer.hidden) selectDrawerTab('database');
11376
+ else closeSettingsDrawer();
11397
11377
  });
11398
11378
  });
11399
11379
  });
@@ -11401,16 +11381,17 @@ var appJs = `
11401
11381
  }
11402
11382
 
11403
11383
  function renderDatabaseNamePanel(host) {
11404
- // Pull the friendly name from /api/databases and the team role from
11384
+ // Pull the friendly name from /api/workspaces and the team role from
11405
11385
  // /api/dbconfig (isCreator) so a non-owner member sees the name
11406
11386
  // read-only \u2014 renaming a team cloud broadcasts to every member, so
11407
11387
  // only the owner may do it.
11408
- Promise.all([fetchJson('/api/databases'), fetchJson('/api/dbconfig').catch(function () { return {}; })])
11388
+ Promise.all([fetchJson('/api/workspaces'), fetchJson('/api/dbconfig').catch(function () { return {}; })])
11409
11389
  .then(function (results) {
11410
11390
  var data = results[0];
11411
11391
  var cfg = results[1] || {};
11412
- var current = (data && data.current) || {};
11413
- var name = current.label || current.dbFile || '';
11392
+ var currentId = (data && data.current) || null;
11393
+ var current = ((data && data.workspaces) || []).filter(function (w) { return w.id === currentId; })[0] || {};
11394
+ var name = current.label || '';
11414
11395
  var isCloud = current.kind === 'cloud';
11415
11396
  var kind = isCloud ? 'Cloud' : 'Local';
11416
11397
  // Members (cloud, non-creator) can't rename. Locals + creators can.
@@ -11428,11 +11409,11 @@ var appJs = `
11428
11409
  '</div>' +
11429
11410
  '<p style="font-size:11px;color:var(--text-muted);margin:6px 0 0">' +
11430
11411
  (canRename
11431
- ? ('Friendly database name shown in the topbar and the dropdown. ' +
11412
+ ? ('Friendly workspace name shown in the topbar and the dropdown. ' +
11432
11413
  (isCloud
11433
- ? 'For cloud databases, the rename is broadcast to every team member in realtime.'
11434
- : 'Saved to the YAML config\\'s name: key.'))
11435
- : 'Only the team owner can rename this cloud database.') +
11414
+ ? 'For cloud workspaces, the rename is broadcast to every member in realtime.'
11415
+ : 'Saved to the workspace registry (and the config name: key).'))
11416
+ : 'Only the workspace owner can rename this cloud workspace.') +
11436
11417
  '</p>' +
11437
11418
  '<div id="db-name-msg" style="margin-top:6px;font-size:12px;color:var(--text-muted)"></div>' +
11438
11419
  '</div>';
@@ -11452,14 +11433,14 @@ var appJs = `
11452
11433
  .then(function (d) {
11453
11434
  if (d.error) { msg.textContent = 'Failed: ' + d.error; return; }
11454
11435
  msg.textContent = 'Saved.';
11455
- // Refresh the topbar dropdown so the new name shows.
11456
- return fetchJson('/api/databases').then(renderDbSwitcher);
11436
+ // Refresh the topbar switcher so the new name shows.
11437
+ return fetchJson('/api/workspaces').then(renderWsSwitcher);
11457
11438
  })
11458
11439
  .catch(function (e) { msg.textContent = 'Failed: ' + e.message; });
11459
11440
  });
11460
11441
  });
11461
11442
  }).catch(function (err) {
11462
- host.innerHTML = '<div class="placeholder">Failed to load database name: ' + escapeHtml(err.message) + '</div>';
11443
+ host.innerHTML = '<div class="placeholder">Failed to load workspace name: ' + escapeHtml(err.message) + '</div>';
11463
11444
  });
11464
11445
  }
11465
11446
 
@@ -11467,25 +11448,22 @@ var appJs = `
11467
11448
  content.innerHTML =
11468
11449
  '<div class="teams-page">' +
11469
11450
  '<h2>Lattice Settings</h2>' +
11470
- '<p class="lead">Every database this lattice can switch to. This is the same list as the header dropdown.</p>' +
11471
- '<div id="lattice-dbs-host"><div class="placeholder" style="padding:18px">Loading databases\u2026</div></div>' +
11451
+ '<p class="lead">Every workspace this lattice can switch to. This is the same list as the header dropdown.</p>' +
11452
+ '<div id="lattice-dbs-host"><div class="placeholder" style="padding:18px">Loading workspaces\u2026</div></div>' +
11472
11453
  '</div>';
11473
11454
  var host = document.getElementById('lattice-dbs-host');
11474
- // Source the SAME list the header dropdown uses (/api/databases) so the
11475
- // two are always 1:1, listed by readable label rather than the raw file.
11476
- fetchJson('/api/databases').then(function (data) {
11477
- var current = data.current || {};
11478
- var rows = (data.configs || []).map(function (c) {
11479
- var kind = c.active
11480
- ? (current.kind === 'cloud' ? 'Cloud (Postgres)' : 'Local (SQLite)')
11481
- : '\u2014';
11482
- var rowLabel = c.label || c.name;
11483
- var del = '<button class="btn danger" data-delete-path="' + escapeHtml(c.path) + '" data-delete-label="' + escapeHtml(rowLabel) + '">Delete</button>';
11484
- return '<tr' + (c.active ? '' : ' class="ws-row" data-switch-path="' + escapeHtml(c.path) + '"') + '>' +
11485
- '<td>' + escapeHtml(rowLabel) + (c.active ? ' <span class="role-tag">active</span>' : '') + '</td>' +
11455
+ // Single source of truth: the workspace registry (same as the header switcher).
11456
+ fetchJson('/api/workspaces').then(function (data) {
11457
+ var currentId = (data && data.current) || null;
11458
+ var workspaces = (data && data.workspaces) || [];
11459
+ var rows = workspaces.map(function (w) {
11460
+ var isActive = w.id === currentId;
11461
+ var kind = w.kind === 'cloud' ? 'Cloud (Postgres)' : 'Local (SQLite)';
11462
+ // Rows are click-to-switch; deletion lives in Workspace Settings \u2192 Danger Zone.
11463
+ return '<tr' + (isActive ? '' : ' class="ws-row" data-switch-id="' + escapeHtml(w.id) + '"') + '>' +
11464
+ '<td>' + escapeHtml(w.label) + (isActive ? ' <span class="role-tag">active</span>' : '') + '</td>' +
11486
11465
  '<td>' + kind + '</td>' +
11487
- '<td><code>' + escapeHtml(c.dbFile || '') + '</code></td>' +
11488
- '<td>' + del + '</td>' +
11466
+ '<td><code>' + escapeHtml(w.dir || '') + '</code></td>' +
11489
11467
  '</tr>';
11490
11468
  }).join('');
11491
11469
  host.innerHTML =
@@ -11495,38 +11473,22 @@ var appJs = `
11495
11473
  '<button class="btn primary" id="action-add-db">+ Add new workspace</button>' +
11496
11474
  '</div>' +
11497
11475
  '<table style="width:100%;border-collapse:collapse">' +
11498
- '<thead><tr style="text-align:left"><th>Name</th><th>Kind</th><th>File / source</th><th>Action</th></tr></thead>' +
11499
- '<tbody>' + (rows || '<tr><td colspan="4" style="padding:8px;color:var(--text-muted)">No workspaces configured.</td></tr>') + '</tbody>' +
11476
+ '<thead><tr style="text-align:left"><th>Name</th><th>Kind</th><th>Location</th></tr></thead>' +
11477
+ '<tbody>' + (rows || '<tr><td colspan="3" style="padding:8px;color:var(--text-muted)">No workspaces configured.</td></tr>') + '</tbody>' +
11500
11478
  '</table>' +
11501
11479
  '</div>';
11502
- host.querySelectorAll('tr.ws-row[data-switch-path]').forEach(function (row) {
11480
+ host.querySelectorAll('tr.ws-row[data-switch-id]').forEach(function (row) {
11503
11481
  row.addEventListener('click', function () {
11504
- var configPath = row.getAttribute('data-switch-path');
11505
- fetch('/api/databases/switch', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: configPath }) })
11482
+ var id = row.getAttribute('data-switch-id');
11483
+ fetch('/api/workspaces/switch', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: id }) })
11506
11484
  .then(function (r) { return r.json(); })
11507
11485
  .then(function () { return reloadEverything(); })
11508
11486
  .then(function () { renderLatticeSettings(document.getElementById('content')); });
11509
11487
  });
11510
11488
  });
11511
- host.querySelectorAll('[data-delete-path]').forEach(function (btn) {
11512
- btn.addEventListener('click', function (e) {
11513
- e.stopPropagation(); // don't trigger the row's switch handler
11514
- confirmDeleteDatabase(
11515
- btn.getAttribute('data-delete-path'),
11516
- btn.getAttribute('data-delete-label'),
11517
- function () {
11518
- // Deleting any row may have switched the active DB (if it was
11519
- // the active one); refetch everything, then re-render the list.
11520
- return reloadEverything().then(function () {
11521
- renderLatticeSettings(document.getElementById('content'));
11522
- });
11523
- },
11524
- );
11525
- });
11526
- });
11527
11489
  host.querySelector('#action-add-db').addEventListener('click', showCreateDatabaseWizard);
11528
11490
  }).catch(function (err) {
11529
- host.innerHTML = '<div class="placeholder">Failed to load databases: ' + escapeHtml(err.message) + '</div>';
11491
+ host.innerHTML = '<div class="placeholder">Failed to load workspaces: ' + escapeHtml(err.message) + '</div>';
11530
11492
  });
11531
11493
  }
11532
11494
 
@@ -11542,7 +11504,7 @@ var appJs = `
11542
11504
  host.innerHTML =
11543
11505
  '<div class="dbconfig-panel" style="margin-bottom:18px;padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">' +
11544
11506
  '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' +
11545
- '<h3 style="margin:0">Database</h3>' +
11507
+ '<h3 style="margin:0">Database connection</h3>' +
11546
11508
  badge +
11547
11509
  '</div>' +
11548
11510
  body +
@@ -11562,20 +11524,16 @@ var appJs = `
11562
11524
  label = 'LOCAL';
11563
11525
  color = 'var(--text-muted)';
11564
11526
  break;
11565
- case 'cloud-connected':
11566
- label = 'CLOUD \xB7 CONNECTED';
11567
- color = 'var(--accent)';
11568
- break;
11569
11527
  case 'team-cloud-creator':
11570
- label = '\u{1F451} TEAM CLOUD \xB7 CREATOR';
11528
+ label = '\u{1F451} CLOUD \xB7 OWNER';
11571
11529
  color = 'var(--accent)';
11572
11530
  break;
11573
11531
  case 'team-cloud-member':
11574
- label = 'TEAM CLOUD \xB7 MEMBER';
11532
+ label = 'CLOUD \xB7 MEMBER';
11575
11533
  color = 'var(--accent)';
11576
11534
  break;
11577
11535
  case 'team-cloud-needs-invite':
11578
- label = 'TEAM CLOUD \xB7 NEEDS INVITE';
11536
+ label = 'CLOUD \xB7 NEEDS INVITE';
11579
11537
  color = 'var(--warn)';
11580
11538
  break;
11581
11539
  default:
@@ -11590,31 +11548,23 @@ var appJs = `
11590
11548
  '<p style="margin:0 0 12px;color:var(--text-muted);font-size:13px">' +
11591
11549
  'SQLite DB: <code>' + escapeHtml(info.dbFile || '(unknown)') + '</code>. ' +
11592
11550
  'Push this workspace to a cloud Postgres to collaborate. ' +
11593
- '(To join an existing cloud, create a new workspace and choose \u201Cjoin via cloud invite\u201D.)' +
11551
+ '(To join a team, create a new workspace and choose \u201CJoin a team (invite)\u201D.)' +
11594
11552
  '</p>' +
11595
11553
  '<div class="team-actions">' +
11596
11554
  '<button class="btn primary" data-act="open-migrate">Migrate to cloud \u2192</button>' +
11597
11555
  '</div>'
11598
11556
  );
11599
11557
  }
11600
- if (info.state === 'cloud-connected') {
11601
- return (
11602
- renderConnectionSummary(info) +
11603
- '<div class="team-actions" style="margin-top:10px">' +
11604
- '<button class="btn primary" data-act="open-upgrade">Upgrade to team cloud \u2192</button>' +
11605
- '</div>'
11606
- );
11607
- }
11608
11558
  if (info.state === 'team-cloud-creator' || info.state === 'team-cloud-member') {
11609
- var isCreator = info.state === 'team-cloud-creator';
11559
+ var isOwner = info.state === 'team-cloud-creator';
11610
11560
  return (
11611
11561
  renderConnectionSummary(info) +
11612
11562
  '<div style="margin-top:10px;font-size:13px">' +
11613
- '<strong>Team:</strong> ' + escapeHtml(info.teamName || '(unnamed)') +
11614
- (isCreator ? ' \xB7 <span style="color:var(--accent)">you are the creator</span>' : ' \xB7 <span style="color:var(--text-muted)">member</span>') +
11563
+ '<strong>Cloud workspace:</strong> ' + escapeHtml(info.teamName || '(unnamed)') +
11564
+ (isOwner ? ' \xB7 <span style="color:var(--accent)">you are the owner</span>' : ' \xB7 <span style="color:var(--text-muted)">member</span>') +
11615
11565
  '</div>' +
11616
11566
  '<div class="team-actions" style="margin-top:10px">' +
11617
- (isCreator ? '<button class="btn primary" data-act="open-invite">Invite member</button>' : '') +
11567
+ (isOwner ? '<button class="btn primary" data-act="open-invite">Invite member</button>' : '') +
11618
11568
  '</div>' +
11619
11569
  // Exit actions (Disconnect for the owner / Leave for a member) live
11620
11570
  // in the Danger Zone below \u2014 not on a member row.
@@ -11625,14 +11575,14 @@ var appJs = `
11625
11575
  return (
11626
11576
  renderConnectionSummary(info) +
11627
11577
  '<p style="margin-top:10px;color:var(--warn);font-size:13px">' +
11628
- 'This cloud DB is a team \u2014 paste your invite token to join.' +
11578
+ 'Not a member of this cloud workspace yet \u2014 paste your invite token to join.' +
11629
11579
  '</p>' +
11630
11580
  '<div style="display:grid;grid-template-columns:1fr;gap:8px;margin-top:6px">' +
11631
11581
  '<div><label class="field-label">Invite token</label>' +
11632
11582
  '<textarea id="db-rejoin-token" placeholder="latinv_..." style="width:100%;height:54px;font-family:JetBrains Mono,monospace"></textarea></div>' +
11633
11583
  '</div>' +
11634
11584
  '<div class="team-actions" style="margin-top:10px">' +
11635
- '<button class="btn primary" data-act="rejoin-with-token">Join team \u2192</button>' +
11585
+ '<button class="btn primary" data-act="rejoin-with-token">Join workspace \u2192</button>' +
11636
11586
  '</div>'
11637
11587
  );
11638
11588
  }
@@ -11662,11 +11612,6 @@ var appJs = `
11662
11612
  showMigrateToCloudModal(rerender);
11663
11613
  });
11664
11614
 
11665
- var upgradeBtn = host.querySelector('[data-act="open-upgrade"]');
11666
- if (upgradeBtn) upgradeBtn.addEventListener('click', function () {
11667
- showUpgradeToTeamModal(rerender);
11668
- });
11669
-
11670
11615
  // team_id / my_user_id / isCreator come from /api/dbconfig (info),
11671
11616
  // resolved against the ACTIVE cloud DB \u2014 not a local connection row
11672
11617
  // (which doesn't exist when the team cloud itself is active). This
@@ -11687,15 +11632,21 @@ var appJs = `
11687
11632
  // rows carry Kick, shown only to the creator.
11688
11633
  var membersHost = host.querySelector('#db-members-host');
11689
11634
  if (membersHost && teamId && (info.state === 'team-cloud-creator' || info.state === 'team-cloud-member')) {
11690
- fetchJson('/api/teams-gui/teams/' + teamId + '/members').then(function (res) {
11691
- var members = res.members || [];
11692
- membersHost.innerHTML = renderMembersList(members, myUserId, isCreator);
11635
+ Promise.all([
11636
+ fetchJson('/api/teams-gui/teams/' + teamId + '/members'),
11637
+ // Pending invitees (I). Resilient: an older cloud without the GET
11638
+ // invitations route shouldn't blank the whole member list.
11639
+ fetchJson('/api/teams-gui/teams/' + teamId + '/invitations').catch(function () { return { invitations: [] }; }),
11640
+ ]).then(function (results) {
11641
+ var members = (results[0] && results[0].members) || [];
11642
+ var invitations = (results[1] && results[1].invitations) || [];
11643
+ membersHost.innerHTML = renderMembersList(members, myUserId, isCreator, invitations);
11693
11644
  // Kick another member (creator only).
11694
11645
  membersHost.querySelectorAll('[data-act="kick"]').forEach(function (btn) {
11695
11646
  var row = btn.closest('[data-user-id]');
11696
11647
  var userId = row && row.getAttribute('data-user-id');
11697
11648
  btn.addEventListener('click', function () {
11698
- if (!confirm('Remove this member from the team?')) return;
11649
+ if (!confirm('Remove this member from the workspace?')) return;
11699
11650
  withBusy(btn, function () {
11700
11651
  return fetchJson('/api/teams-gui/teams/' + teamId + '/members/' + encodeURIComponent(userId), { method: 'DELETE' })
11701
11652
  .then(function () { rerender(); })
@@ -11714,7 +11665,7 @@ var appJs = `
11714
11665
  // call the connect-existing endpoint with just the invite
11715
11666
  // token. The handler reads credentials from db-credentials.enc
11716
11667
  // via the active configPath's label.
11717
- setMsg('Joining team\u2026');
11668
+ setMsg('Joining workspace\u2026');
11718
11669
  fetch('/api/dbconfig/connect-existing', {
11719
11670
  method: 'POST', headers: { 'content-type': 'application/json' },
11720
11671
  body: JSON.stringify({
@@ -11877,7 +11828,7 @@ var appJs = `
11877
11828
  '<div style="margin-top:14px;padding:10px;border:1px solid var(--border);border-radius:6px;background:rgba(255,255,255,0.02)">' +
11878
11829
  '<div style="font-size:12px;color:var(--text);text-transform:uppercase;letter-spacing:0.04em;font-weight:500;margin-bottom:6px">Share with cloud</div>' +
11879
11830
  '<p style="margin:0 0 8px;font-size:12px;color:var(--text-muted)">' +
11880
- 'Checked tables become visible to every team member you invite. Uncheck any you want to keep ' +
11831
+ 'Checked tables become visible to every member you invite. Uncheck any you want to keep ' +
11881
11832
  'cloud-stored but unshared. You can change this later from Data Model.' +
11882
11833
  '</p>' +
11883
11834
  shareRows +
@@ -11900,7 +11851,7 @@ var appJs = `
11900
11851
  return probeBeforeCredentialSave(body, msg).then(function (probe) {
11901
11852
  if (probe.teamEnabled) {
11902
11853
  throw new Error(
11903
- 'Target is already a cloud DB with a team' +
11854
+ 'Target is already a cloud workspace' +
11904
11855
  (probe.teamName ? ' (' + probe.teamName + ')' : '') +
11905
11856
  '. Migrate-to-cloud only works against fresh empty targets.'
11906
11857
  );
@@ -11951,100 +11902,11 @@ var appJs = `
11951
11902
  });
11952
11903
  }
11953
11904
 
11954
- function showConnectExistingModal(onClose) {
11955
- var bodyHtml =
11956
- '<p style="margin:0 0 12px;font-size:13px;color:var(--text-muted)">' +
11957
- 'Switch this project to an <strong>existing</strong> cloud Postgres. ' +
11958
- 'Your local SQLite file is preserved \u2014 only this project\\'s active ' +
11959
- 'connection changes. Switch back any time by editing ' +
11960
- '<code>lattice.config.yml</code>\\'s <code>db:</code> line or via the ' +
11961
- 'Databases catalog under User Config. If you want to <em>push</em> ' +
11962
- 'your local rows into the target instead, use Migrate to cloud. If ' +
11963
- 'the target is a teams DB you\\'ll be asked for an invite token ' +
11964
- 'after the probe.' +
11965
- '</p>' +
11966
- postgresFormHtml({}) +
11967
- '<div id="w-team-zone" style="margin-top:10px"></div>' +
11968
- '<div id="w-msg" style="margin-top:10px;font-size:12px;color:var(--text-muted)"></div>';
11969
- var teamZoneShown = false;
11970
- showModal('Connect to existing cloud', bodyHtml, {
11971
- primaryLabel: 'Connect \u2192',
11972
- onSubmit: function () {
11973
- var body = readPostgresWizardForm();
11974
- var msg = document.getElementById('w-msg');
11975
- // probeBeforeCredentialSave validates Supabase form patterns
11976
- // before sending the probe; surfaces inline warnings (with
11977
- // hints) when the user clearly has e.g. the wrong port or
11978
- // missing tenant prefix in the pooler user.
11979
- return probeBeforeCredentialSave(body, msg)
11980
- .then(function (probe) {
11981
- if (probe.teamEnabled && !teamZoneShown) {
11982
- var zone = document.getElementById('w-team-zone');
11983
- zone.innerHTML =
11984
- '<div style="padding:10px;background:rgba(251,146,60,0.08);border:1px solid var(--warn);border-radius:6px">' +
11985
- '<p style="margin:0 0 8px;font-size:13px;color:var(--warn)">Target is a teams DB' +
11986
- (probe.teamName ? ' (<strong>' + escapeHtml(probe.teamName) + '</strong>)' : '') +
11987
- '. Paste your invite token to join:</p>' +
11988
- '<textarea id="w-invite-token" placeholder="latinv_..." style="width:100%;height:54px;font-family:JetBrains Mono,monospace"></textarea>' +
11989
- '</div>';
11990
- teamZoneShown = true;
11991
- msg.textContent = 'Enter invite token, then click Connect again.';
11992
- throw new Error('__PROBE_REQUIRES_TOKEN__');
11993
- }
11994
- // Either non-team, or we already showed the token zone.
11995
- var tokenEl = document.getElementById('w-invite-token');
11996
- var payload = Object.assign({}, body);
11997
- if (tokenEl && tokenEl.value.trim()) payload.invite_token = tokenEl.value.trim();
11998
- msg.textContent = 'Connecting\u2026';
11999
- return fetch('/api/dbconfig/connect-existing', {
12000
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload),
12001
- })
12002
- .then(function (r) { return r.json().then(function (d) { return { status: r.status, body: d }; }); })
12003
- .then(function (r) {
12004
- if (!r.body.ok) throw new Error(r.body.error || ('HTTP ' + r.status));
12005
- if (onClose) onClose();
12006
- });
12007
- })
12008
- .catch(function (e) {
12009
- if (e.message === '__PROBE_REQUIRES_TOKEN__') {
12010
- // Suppress error \u2014 token zone is now visible.
12011
- throw new Error(' '); // forces modal to stay open with a no-op message
12012
- }
12013
- throw e;
12014
- });
12015
- },
12016
- });
12017
- }
12018
-
12019
- function showUpgradeToTeamModal(onClose) {
12020
- var bodyHtml =
12021
- '<p style="margin:0 0 12px;font-size:13px;color:var(--text-muted)">' +
12022
- 'Upgrade this cloud DB to a team DB by registering as the founding member. ' +
12023
- 'Your display name + email from <strong>User Config \u2192 Identity</strong> are used.' +
12024
- '</p>' +
12025
- '<div><label class="field-label">Team name</label>' +
12026
- '<input type="text" id="w-team-name" placeholder="Atlas" style="width:100%"></div>' +
12027
- '<div id="w-msg" style="margin-top:10px;font-size:12px;color:var(--text-muted)"></div>';
12028
- showModal('Upgrade to team cloud', bodyHtml, {
12029
- primaryLabel: 'Upgrade \u2192',
12030
- onSubmit: function () {
12031
- var teamName = (document.getElementById('w-team-name').value || '').trim();
12032
- if (!teamName) throw new Error('Team name is required.');
12033
- var msg = document.getElementById('w-msg');
12034
- msg.textContent = 'Upgrading\u2026';
12035
- return fetch('/api/dbconfig/upgrade-to-team', {
12036
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ team_name: teamName }),
12037
- })
12038
- .then(function (r) { return r.json().then(function (d) { return { status: r.status, body: d }; }); })
12039
- .then(function (r) {
12040
- if (!r.body.ok) throw new Error(r.body.error || ('HTTP ' + r.status));
12041
- if (onClose) onClose();
12042
- });
12043
- },
12044
- });
12045
- }
11905
+ // (Removed in 1.16.3) The standalone upgrade-to-cloud-sharing modal is
11906
+ // gone; cloud workspaces initialize their member/share machinery
11907
+ // automatically (see TeamsClient.ensureCloudWorkspaceIdentity).
12046
11908
 
12047
- function renderMembersList(members, myUserId, isCreator) {
11909
+ function renderMembersList(members, myUserId, isCreator, invitations) {
12048
11910
  var rows = members.map(function (m) {
12049
11911
  var label = m.name || m.email || '(unknown)';
12050
11912
  var isSelf = m.user_id === myUserId;
@@ -12064,30 +11926,89 @@ var appJs = `
12064
11926
  btn +
12065
11927
  '</div>';
12066
11928
  }).join('');
12067
- return '<div class="members-list"><h4>Members</h4>' + rows + '</div>';
11929
+ // Pending (unredeemed) invitations \u2014 shown below active members so the
11930
+ // owner can see who's been invited but hasn't joined yet (I).
11931
+ var pending = (invitations || []).filter(function (iv) { return iv && iv.invitee_email; });
11932
+ var pendingHtml = pending.length
11933
+ ? '<h4 style="margin-top:14px">Pending invitations</h4>' +
11934
+ pending.map(function (iv) {
11935
+ return '<div class="member-row member-row-pending">' +
11936
+ '<span style="color:var(--text-muted)">' + escapeHtml(iv.invitee_email) +
11937
+ ' <span class="role-tag' + (iv.expired ? ' role-expired' : ' role-member') + '">' +
11938
+ (iv.expired ? 'expired' : 'invited') +
11939
+ '</span>' +
11940
+ '</span>' +
11941
+ '</div>';
11942
+ }).join('')
11943
+ : '';
11944
+ return '<div class="members-list"><h4>Members</h4>' + rows + pendingHtml + '</div>';
12068
11945
  }
12069
11946
 
12070
11947
  function showInviteByEmailModal(teamId, info) {
11948
+ // Owner-facing: list the workspace's shareable tables, ALL CHECKED by
11949
+ // default, so inviting a member shares those tables with them in one step.
11950
+ // Uncheck any you want to keep private. Re-sharing an already-shared table
11951
+ // is idempotent, so it's safe to leave them checked.
11952
+ var shareable = ((state.entities && state.entities.tables) || [])
11953
+ .filter(function (t) { return t.name.charAt(0) !== '_' && !isJunction(t); })
11954
+ .map(function (t) { return t.name; });
11955
+ var shareRows = shareable.length === 0
11956
+ ? '<p style="margin:0;color:var(--text-muted);font-size:12px">No tables to share yet.</p>'
11957
+ : shareable.map(function (t) {
11958
+ return '<label style="display:flex;align-items:center;gap:8px;padding:4px 0;font-weight:400;text-transform:none;letter-spacing:0">' +
11959
+ '<input type="checkbox" class="invite-share" data-table="' + escapeHtml(t) + '" checked />' +
11960
+ '<span style="font-family:ui-monospace,monospace;font-size:12.5px">' + escapeHtml(t) + '</span>' +
11961
+ '</label>';
11962
+ }).join('');
12071
11963
  var bodyHtml =
12072
11964
  '<div class="field"><label>Invitee email</label>' +
12073
11965
  '<input name="invitee_email" type="email" placeholder="bob@example.com" /></div>' +
12074
- '<p style="font-size:12px;color:var(--text-muted);margin:0">' +
11966
+ '<p style="font-size:12px;color:var(--text-muted);margin:0 0 10px">' +
12075
11967
  'Invitations are bound to this email \u2014 only the recipient can redeem.' +
12076
- '</p>';
11968
+ '</p>' +
11969
+ (shareable.length > 0
11970
+ ? '<div style="margin-top:4px;padding:10px;border:1px solid var(--border);border-radius:6px;background:rgba(255,255,255,0.02)">' +
11971
+ '<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:6px">Share tables with this member</div>' +
11972
+ '<p style="margin:0 0 8px;font-size:12px;color:var(--text-muted)">All tables are shared by default \u2014 uncheck any you want to keep private.</p>' +
11973
+ shareRows +
11974
+ '</div>'
11975
+ : '');
12077
11976
  showModal('Invite member', bodyHtml, {
12078
11977
  primaryLabel: 'Generate invite',
12079
11978
  onSubmit: function (scope) {
12080
11979
  var data = collectFormValues(scope);
12081
11980
  if (!data.invitee_email) throw new Error('invitee_email is required');
11981
+ var tablesToShare = [];
11982
+ scope.querySelectorAll('input.invite-share:checked').forEach(function (cb) {
11983
+ tablesToShare.push(cb.getAttribute('data-table'));
11984
+ });
12082
11985
  return fetchJson('/api/teams-gui/teams/' + teamId + '/invitations', {
12083
11986
  method: 'POST',
12084
11987
  headers: { 'content-type': 'application/json' },
12085
11988
  body: JSON.stringify({ invitee_email: data.invitee_email }),
12086
- }).then(function (inv) { showInviteTokenModal(inv, info); });
11989
+ }).then(function (inv) {
11990
+ // Share the checked tables, then show the invite token.
11991
+ return shareTablesForTeam(teamId, tablesToShare).then(function () {
11992
+ showInviteTokenModal(inv, info);
11993
+ });
11994
+ });
12087
11995
  },
12088
11996
  });
12089
11997
  }
12090
11998
 
11999
+ /** Share each table with the team sequentially (idempotent; per-table errors toast, don't abort). */
12000
+ function shareTablesForTeam(teamId, tables) {
12001
+ return (tables || []).reduce(function (chain, table) {
12002
+ return chain.then(function () {
12003
+ return fetchJson('/api/teams-gui/teams/' + encodeURIComponent(teamId) + '/shared', {
12004
+ method: 'POST',
12005
+ headers: { 'content-type': 'application/json' },
12006
+ body: JSON.stringify({ table: table }),
12007
+ }).catch(function (err) { showToast('Share "' + table + '" failed: ' + err.message, {}); });
12008
+ });
12009
+ }, Promise.resolve());
12010
+ }
12011
+
12091
12012
  function showInviteTokenModal(inv, info) {
12092
12013
  info = info || {};
12093
12014
  // The invitee needs the cloud connection string AND the token. Show the
@@ -12165,16 +12086,7 @@ var guiAppHtml = `<!doctype html>
12165
12086
  <circle cx="18" cy="18" r="1.5" fill="#bef264"/>
12166
12087
  </svg>
12167
12088
  </a>
12168
- <div class="db-switcher" id="db-switcher-host">
12169
- <button class="db-button" id="db-button" title="Switch database">
12170
- <span class="db-status" id="db-status" title="Local"></span>
12171
- <span class="db-icon">\u{1F4BE}</span>
12172
- <span class="db-name" id="db-name">loading\u2026</span>
12173
- <span class="db-caret">\u25BE</span>
12174
- </button>
12175
- <div class="db-menu" id="db-menu" hidden></div>
12176
- </div>
12177
- <div class="db-switcher" id="ws-switcher" hidden>
12089
+ <div class="db-switcher" id="ws-switcher">
12178
12090
  <button class="db-button" id="ws-button" title="Switch workspace">
12179
12091
  <span class="db-status" id="ws-status" title="Workspace"></span>
12180
12092
  <span class="db-icon">\u{1F4C2}</span>
@@ -12225,7 +12137,7 @@ var guiAppHtml = `<!doctype html>
12225
12137
  <button class="drawer-close" id="drawer-close" title="Close" aria-label="Close settings">\u2715</button>
12226
12138
  </div>
12227
12139
  <div class="drawer-tabs" id="drawer-tabs">
12228
- <button class="drawer-tab" data-tab="database">Database</button>
12140
+ <button class="drawer-tab" data-tab="database">Workspace</button>
12229
12141
  <button class="drawer-tab" data-tab="lattice">Lattice</button>
12230
12142
  <button class="drawer-tab" data-tab="user">User</button>
12231
12143
  </div>
@@ -12803,11 +12715,15 @@ async function applySchemaSpec(db, table, spec) {
12803
12715
  } catch {
12804
12716
  cols = [];
12805
12717
  }
12718
+ const def = deserializeSchema(spec, db.getDialect());
12806
12719
  if (cols.length === 0) {
12807
- const def = deserializeSchema(spec, db.getDialect());
12808
12720
  await db.defineLate(table, def);
12809
12721
  return true;
12810
12722
  }
12723
+ const alreadyRegistered = db.getRegisteredColumns(table) !== null;
12724
+ if (!alreadyRegistered) {
12725
+ await db.defineLate(table, def);
12726
+ }
12811
12727
  const pk = db.getPrimaryKey(table);
12812
12728
  const { addColumns } = diffSchemaForAdditive(table, spec, cols, pk);
12813
12729
  for (const colName of addColumns) {
@@ -12816,7 +12732,7 @@ async function applySchemaSpec(db, table, spec) {
12816
12732
  const sqlType = renderAddColumnType(colSpec, db.getDialect());
12817
12733
  await db.addColumn(table, colName, sqlType);
12818
12734
  }
12819
- return addColumns.length > 0;
12735
+ return !alreadyRegistered || addColumns.length > 0;
12820
12736
  }
12821
12737
 
12822
12738
  // src/teams/team-core.ts
@@ -12864,6 +12780,22 @@ async function listTeamMembers(db, teamId) {
12864
12780
  }
12865
12781
  return out;
12866
12782
  }
12783
+ async function listPendingInvitations(db, teamId) {
12784
+ const rows = await db.query("__lattice_invitations", {
12785
+ filters: [
12786
+ { col: "team_id", op: "eq", val: teamId },
12787
+ { col: "redeemed_at", op: "isNull" }
12788
+ ]
12789
+ });
12790
+ const nowMs = Date.now();
12791
+ return rows.map((r) => ({
12792
+ id: r.id,
12793
+ invitee_email: r.invitee_email,
12794
+ invited_at: r.created_at,
12795
+ expires_at: r.expires_at ?? null,
12796
+ expired: r.expires_at != null && new Date(r.expires_at).getTime() < nowMs
12797
+ })).sort((a, b) => a.invited_at < b.invited_at ? 1 : a.invited_at > b.invited_at ? -1 : 0);
12798
+ }
12867
12799
  async function appendChangeEnvelope(db, entry) {
12868
12800
  const rows = await db.query("__lattice_change_log", {
12869
12801
  filters: [{ col: "team_id", op: "eq", val: entry.team_id }],
@@ -12984,6 +12916,9 @@ async function unshareObject(db, teamId, table) {
12984
12916
  async function listMembersDirect(db, teamId) {
12985
12917
  return listTeamMembers(db, teamId);
12986
12918
  }
12919
+ async function listPendingInvitationsDirect(db, teamId) {
12920
+ return listPendingInvitations(db, teamId);
12921
+ }
12987
12922
  async function inviteDirect(db, teamId, inviterUserId, inviteeEmail, expiresInHours = 7 * 24) {
12988
12923
  const team = await db.get("__lattice_team", teamId);
12989
12924
  if (!team || team.deleted_at) {
@@ -13900,7 +13835,7 @@ function sendJson2(res, body, status = 200) {
13900
13835
  res.end(JSON.stringify(body));
13901
13836
  }
13902
13837
  function readJson2(req) {
13903
- return new Promise((resolve10, reject) => {
13838
+ return new Promise((resolve11, reject) => {
13904
13839
  let raw = "";
13905
13840
  req.setEncoding("utf8");
13906
13841
  req.on("data", (chunk) => {
@@ -13909,7 +13844,7 @@ function readJson2(req) {
13909
13844
  });
13910
13845
  req.on("end", () => {
13911
13846
  try {
13912
- resolve10(raw ? JSON.parse(raw) : {});
13847
+ resolve11(raw ? JSON.parse(raw) : {});
13913
13848
  } catch (e) {
13914
13849
  reject(new Error(`Invalid JSON body: ${e.message}`));
13915
13850
  }
@@ -13984,6 +13919,10 @@ async function dispatchTeamRoute(req, res, ctx) {
13984
13919
  await handleCreateInvitation(req, res, ctx, invitationsMatch[1] ?? "");
13985
13920
  return true;
13986
13921
  }
13922
+ if (invitationsMatch && method === "GET") {
13923
+ await handleListInvitations(res, ctx, invitationsMatch[1] ?? "");
13924
+ return true;
13925
+ }
13987
13926
  const objectsListMatch = /^\/api\/teams\/([^/]+)\/objects$/.exec(pathname);
13988
13927
  if (objectsListMatch) {
13989
13928
  if (method === "POST") {
@@ -14260,12 +14199,24 @@ async function handleListMembers(res, ctx, teamId) {
14260
14199
  }
14261
14200
  sendJson2(res, { members: await listTeamMembers(ctx.db, teamId) });
14262
14201
  }
14263
- async function handleKickMember(res, ctx, teamId, userId) {
14202
+ async function handleListInvitations(res, ctx, teamId) {
14264
14203
  if (!ctx.authContext) {
14265
14204
  sendJson2(res, { error: "Unauthorized" }, 401);
14266
14205
  return;
14267
14206
  }
14268
- const callerRole = await getMembershipRole(ctx.db, teamId, ctx.authContext.user.id);
14207
+ const role = await getMembershipRole(ctx.db, teamId, ctx.authContext.user.id);
14208
+ if (!role) {
14209
+ sendJson2(res, { error: "Not a member of this team" }, 403);
14210
+ return;
14211
+ }
14212
+ sendJson2(res, { invitations: await listPendingInvitations(ctx.db, teamId) });
14213
+ }
14214
+ async function handleKickMember(res, ctx, teamId, userId) {
14215
+ if (!ctx.authContext) {
14216
+ sendJson2(res, { error: "Unauthorized" }, 401);
14217
+ return;
14218
+ }
14219
+ const callerRole = await getMembershipRole(ctx.db, teamId, ctx.authContext.user.id);
14269
14220
  if (!callerRole) {
14270
14221
  sendJson2(res, { error: "Not a member of this team" }, 403);
14271
14222
  return;
@@ -14970,6 +14921,42 @@ var TeamsClient = class {
14970
14921
  });
14971
14922
  return reg;
14972
14923
  }
14924
+ /**
14925
+ * Idempotently initialize a cloud Postgres DB as a collaborative cloud
14926
+ * workspace (members + sharing). 1.16.3 deprecated the user-facing "team"
14927
+ * concept and the explicit "upgrade to team" step — every cloud workspace
14928
+ * gets this machinery automatically at migrate / connect / open time, so the
14929
+ * members + per-table sharing surface is always available on a cloud DB.
14930
+ *
14931
+ * No-op (returns created:false) when the cloud already carries an identity.
14932
+ * On a fresh cloud the caller becomes the owner. Race-safe: a concurrent
14933
+ * initializer that wins the singleton insert is treated as success.
14934
+ */
14935
+ async ensureCloudWorkspaceIdentity(opts) {
14936
+ const probe = await probeCloud(opts.cloudUrl);
14937
+ if (!probe.reachable) {
14938
+ throw new Error(`Cloud DB unreachable: ${probe.error ?? "unknown error"}`);
14939
+ }
14940
+ if (probe.teamEnabled) return { created: false };
14941
+ if (!opts.email) {
14942
+ throw new Error("Set your email in User settings to set up this cloud workspace.");
14943
+ }
14944
+ try {
14945
+ const displayName = opts.displayName?.trim() ? opts.displayName : opts.email;
14946
+ await this.upgradeToTeamCloud({
14947
+ label: opts.label,
14948
+ cloudUrl: opts.cloudUrl,
14949
+ teamName: opts.workspaceName,
14950
+ email: opts.email,
14951
+ displayName
14952
+ });
14953
+ return { created: true };
14954
+ } catch (e) {
14955
+ const msg = e.message || "";
14956
+ if (/already has (a team|users)/i.test(msg)) return { created: false };
14957
+ throw e;
14958
+ }
14959
+ }
14973
14960
  // ── Cloud team operations (dispatch on URL scheme) ──────────────────────
14974
14961
  // For HTTP cloud URLs (`http://lattice-server:port`), every operation
14975
14962
  // round-trips through the team server's authenticated REST API. For
@@ -15003,6 +14990,18 @@ var TeamsClient = class {
15003
14990
  );
15004
14991
  return r.members;
15005
14992
  }
14993
+ async listPendingInvitations(cloudUrl, token, teamId) {
14994
+ if (isPostgresUrl(cloudUrl)) {
14995
+ return listPendingInvitationsDirect(this.local, teamId);
14996
+ }
14997
+ const r = await this.fetchAuthed(
14998
+ cloudUrl,
14999
+ token,
15000
+ "GET",
15001
+ `/api/teams/${teamId}/invitations`
15002
+ );
15003
+ return r.invitations;
15004
+ }
15006
15005
  async invite(cloudUrl, token, teamId, inviteeEmail, expiresInHours, inviterUserId) {
15007
15006
  if (isPostgresUrl(cloudUrl)) {
15008
15007
  if (!inviterUserId) {
@@ -15750,14 +15749,143 @@ var TeamsHttpError = class extends Error {
15750
15749
  };
15751
15750
 
15752
15751
  // src/gui/teams-routes.ts
15753
- import { existsSync as existsSync15, readFileSync as readFileSync11, readdirSync as readdirSync5, rmSync, writeFileSync as writeFileSync6 } from "fs";
15754
- import { dirname as dirname6, join as join14 } from "path";
15752
+ import { existsSync as existsSync17, readFileSync as readFileSync12, readdirSync as readdirSync6, rmSync, writeFileSync as writeFileSync6 } from "fs";
15753
+ import { dirname as dirname8, join as join16 } from "path";
15754
+
15755
+ // src/framework/gui-bootstrap.ts
15756
+ import { existsSync as existsSync16, readFileSync as readFileSync11, readdirSync as readdirSync5 } from "fs";
15757
+ import { basename as basename4, dirname as dirname7, join as join15, resolve as resolve6 } from "path";
15758
+ import { parse as parseYaml } from "yaml";
15759
+
15760
+ // src/framework/migrate-to-root.ts
15761
+ import { cpSync, existsSync as existsSync15, mkdirSync as mkdirSync8 } from "fs";
15762
+ import { homedir as homedir3 } from "os";
15763
+ import { join as join14 } from "path";
15764
+ var LEGACY_ENTRIES = [
15765
+ "master.key",
15766
+ "identity.json",
15767
+ "preferences.json",
15768
+ "db-credentials.enc",
15769
+ "keys"
15770
+ ];
15771
+ function importLegacyUserConfig(root) {
15772
+ const legacy = process.env.LATTICE_CONFIG_DIR ?? join14(homedir3(), ".lattice");
15773
+ const dest = rootConfigDir(root);
15774
+ const copied = [];
15775
+ if (!existsSync15(join14(legacy, "master.key"))) return { migrated: false, copied };
15776
+ if (existsSync15(join14(dest, "master.key"))) return { migrated: false, copied };
15777
+ mkdirSync8(dest, { recursive: true });
15778
+ for (const entry of LEGACY_ENTRIES) {
15779
+ const src = join14(legacy, entry);
15780
+ if (existsSync15(src)) {
15781
+ cpSync(src, join14(dest, entry), { recursive: true });
15782
+ copied.push(entry);
15783
+ }
15784
+ }
15785
+ return copied.length > 0 ? { migrated: true, from: legacy, copied } : { migrated: false, copied };
15786
+ }
15787
+
15788
+ // src/framework/gui-bootstrap.ts
15789
+ function resolveContextDirForConfig(configPath) {
15790
+ const base = dirname7(resolve6(configPath));
15791
+ for (const dir of ["context", ".", "generated"]) {
15792
+ const abs = resolve6(base, dir);
15793
+ if (existsSync16(join15(abs, ".lattice", "manifest.json"))) return abs;
15794
+ }
15795
+ return resolve6(base, "context");
15796
+ }
15797
+ function readConfigMeta(absPath) {
15798
+ let raw;
15799
+ try {
15800
+ raw = readFileSync11(absPath, "utf8");
15801
+ } catch {
15802
+ return null;
15803
+ }
15804
+ let parsed;
15805
+ try {
15806
+ parsed = parseYaml(raw);
15807
+ } catch {
15808
+ return null;
15809
+ }
15810
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
15811
+ const cfg = parsed;
15812
+ if (typeof cfg.db !== "string" || !cfg.db.trim()) return null;
15813
+ if (!cfg.entities || typeof cfg.entities !== "object" || Array.isArray(cfg.entities)) return null;
15814
+ const name = typeof cfg.name === "string" && cfg.name.trim() ? cfg.name.trim() : void 0;
15815
+ return name !== void 0 ? { db: cfg.db.trim(), name } : { db: cfg.db.trim() };
15816
+ }
15817
+ function nameFromConfigPath(absPath) {
15818
+ return basename4(absPath).replace(/\.(config\.)?ya?ml$/i, "") || "Workspace";
15819
+ }
15820
+ function adoptConfigAsWorkspace(root, configPath, opts) {
15821
+ const abs = resolve6(configPath);
15822
+ const meta = readConfigMeta(abs);
15823
+ if (!meta) return null;
15824
+ return addAdoptedWorkspace(root, {
15825
+ displayName: opts?.displayName ?? meta.name ?? nameFromConfigPath(abs),
15826
+ db: meta.db,
15827
+ configPath: abs,
15828
+ contextDir: resolveContextDirForConfig(abs),
15829
+ makeActive: opts?.makeActive ?? false
15830
+ });
15831
+ }
15832
+ function reconcileWorkspaceRegistry(root, scanDirs) {
15833
+ const seen = /* @__PURE__ */ new Set();
15834
+ for (const dir of scanDirs) {
15835
+ const abs = resolve6(dir);
15836
+ if (seen.has(abs) || !existsSync16(abs)) continue;
15837
+ seen.add(abs);
15838
+ let entries;
15839
+ try {
15840
+ entries = readdirSync5(abs);
15841
+ } catch {
15842
+ continue;
15843
+ }
15844
+ for (const fname of entries) {
15845
+ if (!fname.endsWith(".yml") && !fname.endsWith(".yaml")) continue;
15846
+ const full = join15(abs, fname);
15847
+ if (findWorkspaceByConfigPath(root, full)) continue;
15848
+ adoptConfigAsWorkspace(root, full, { makeActive: false });
15849
+ }
15850
+ }
15851
+ }
15852
+ function ensureRootForGui(opts) {
15853
+ const configAbs = resolve6(opts.configPath);
15854
+ const hasConfigFile = existsSync16(configAbs);
15855
+ let root = findLatticeRoot(opts.startDir);
15856
+ if (!root && hasConfigFile) root = findLatticeRoot(dirname7(configAbs));
15857
+ let freshRoot = false;
15858
+ if (!root) {
15859
+ root = ensureLatticeRoot(hasConfigFile ? dirname7(configAbs) : opts.startDir);
15860
+ freshRoot = true;
15861
+ }
15862
+ importLegacyUserConfig(root);
15863
+ if (hasConfigFile && (opts.explicitConfig || freshRoot || getActiveWorkspace(root) === null)) {
15864
+ adoptConfigAsWorkspace(root, configAbs, {
15865
+ makeActive: true,
15866
+ ...opts.displayName !== void 0 ? { displayName: opts.displayName } : {}
15867
+ });
15868
+ }
15869
+ reconcileWorkspaceRegistry(root, [dirname7(configAbs), dirname7(root)]);
15870
+ const ws = getActiveWorkspace(root) ?? addWorkspace(root, { displayName: opts.displayName ?? "My Workspace" });
15871
+ const paths = resolveWorkspacePaths(root, ws);
15872
+ return {
15873
+ root,
15874
+ workspaceId: ws.id,
15875
+ displayName: ws.displayName,
15876
+ configPath: paths.configPath,
15877
+ contextDir: paths.contextDir
15878
+ };
15879
+ }
15880
+
15881
+ // src/gui/teams-routes.ts
15755
15882
  function removeTeamConfigForCloud(ctx, cloudUrl) {
15756
15883
  try {
15757
- const dir = dirname6(ctx.configPath);
15758
- for (const fname of readdirSync5(dir)) {
15884
+ const dir = dirname8(ctx.configPath);
15885
+ const root = findLatticeRoot(dir);
15886
+ for (const fname of readdirSync6(dir)) {
15759
15887
  if (!fname.endsWith(".yml") && !fname.endsWith(".yaml")) continue;
15760
- const full = join14(dir, fname);
15888
+ const full = join16(dir, fname);
15761
15889
  let resolvedDb;
15762
15890
  try {
15763
15891
  resolvedDb = parseConfigFile(full).dbPath;
@@ -15765,7 +15893,7 @@ function removeTeamConfigForCloud(ctx, cloudUrl) {
15765
15893
  continue;
15766
15894
  }
15767
15895
  if (resolvedDb !== cloudUrl) continue;
15768
- const raw = readFileSync11(full, "utf8");
15896
+ const raw = readFileSync12(full, "utf8");
15769
15897
  const labelMatch = /^\s*db:\s*\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}/m.exec(raw);
15770
15898
  if (labelMatch?.[1]) {
15771
15899
  try {
@@ -15773,15 +15901,16 @@ function removeTeamConfigForCloud(ctx, cloudUrl) {
15773
15901
  } catch {
15774
15902
  }
15775
15903
  }
15904
+ if (root) removeWorkspaceByConfigPath(root, full);
15776
15905
  rmSync(full, { force: true });
15777
15906
  }
15778
15907
  } catch {
15779
15908
  }
15780
15909
  }
15781
15910
  function writeTeamConfigYaml(activeConfigPath, credentialLabel, teamName) {
15782
- const projectDir = dirname6(activeConfigPath);
15783
- const yamlPath = join14(projectDir, `${credentialLabel}.yml`);
15784
- if (existsSync15(yamlPath)) return yamlPath;
15911
+ const projectDir = dirname8(activeConfigPath);
15912
+ const yamlPath = join16(projectDir, `${credentialLabel}.yml`);
15913
+ if (existsSync17(yamlPath)) return yamlPath;
15785
15914
  const safeName = teamName.replace(/[\r\n]/g, " ");
15786
15915
  const yaml = `# Joined-team config \u2014 managed by lattice gui. Edit entities: to add
15787
15916
  # locally-projected tables of the team's shared data; the cloud DB at
@@ -15907,6 +16036,15 @@ async function dispatchTeamSubroute(req, res, ctx, teamId, subpath) {
15907
16036
  sendJson(res, { members });
15908
16037
  return;
15909
16038
  }
16039
+ if (subpath === "invitations" && method === "GET") {
16040
+ const invitations = await ctx.client.listPendingInvitations(
16041
+ conn.cloud_url,
16042
+ conn.api_token,
16043
+ teamId
16044
+ );
16045
+ sendJson(res, { invitations });
16046
+ return;
16047
+ }
15910
16048
  if (subpath === "invitations" && method === "POST") {
15911
16049
  const body = await readJson(req);
15912
16050
  const inviteeEmail = requireString2(body, "invitee_email");
@@ -16027,13 +16165,32 @@ async function handleJoin(req, res, ctx) {
16027
16165
  cloudUrl
16028
16166
  });
16029
16167
  const configYamlPath = writeTeamConfigYaml(ctx.configPath, credentialLabel, result.team.name);
16168
+ const workspaceId = registerJoinedCloudWorkspace(
16169
+ ctx.configPath,
16170
+ configYamlPath,
16171
+ credentialLabel,
16172
+ result.team.name
16173
+ );
16030
16174
  sendJson(res, {
16031
16175
  ok: true,
16032
16176
  team: result.team,
16033
16177
  user: result.user,
16034
16178
  credential_label: credentialLabel,
16035
- config_path: configYamlPath
16179
+ config_path: configYamlPath,
16180
+ ...workspaceId ? { workspace_id: workspaceId } : {}
16181
+ });
16182
+ }
16183
+ function registerJoinedCloudWorkspace(activeConfigPath, configYamlPath, credentialLabel, teamName) {
16184
+ const root = findLatticeRoot(dirname8(activeConfigPath));
16185
+ if (!root) return null;
16186
+ const ws = registerOrUpdateCloudWorkspace(root, {
16187
+ configPath: configYamlPath,
16188
+ contextDir: resolveContextDirForConfig(configYamlPath),
16189
+ displayName: teamName,
16190
+ db: "${LATTICE_DB:" + credentialLabel + "}",
16191
+ makeActive: false
16036
16192
  });
16193
+ return ws.id;
16037
16194
  }
16038
16195
  async function handleRegisterAndCreate(req, res, ctx) {
16039
16196
  const body = await readJson(req);
@@ -16045,7 +16202,7 @@ async function handleRegisterAndCreate(req, res, ctx) {
16045
16202
  sendJson(res, { error: "cloud_url, email, user_name, team_name required" }, 400);
16046
16203
  return;
16047
16204
  }
16048
- const reg = await ctx.client.register(cloudUrl, email, userName, teamName);
16205
+ const reg = isPostgresUrl(cloudUrl) ? await registerDirectViaPostgres(cloudUrl, email, userName, teamName) : await ctx.client.register(cloudUrl, email, userName, teamName);
16049
16206
  await ctx.client.saveConnection({
16050
16207
  team_id: reg.team.id,
16051
16208
  team_name: reg.team.name,
@@ -16053,7 +16210,26 @@ async function handleRegisterAndCreate(req, res, ctx) {
16053
16210
  my_user_id: reg.user.id,
16054
16211
  api_token: reg.raw_token
16055
16212
  });
16056
- sendJson(res, { ok: true, team: reg.team, user: reg.user });
16213
+ const credentialLabel = saveDbCredentialForTeam({
16214
+ teamName: reg.team.name,
16215
+ teamId: reg.team.id,
16216
+ cloudUrl
16217
+ });
16218
+ const configYamlPath = writeTeamConfigYaml(ctx.configPath, credentialLabel, reg.team.name);
16219
+ const workspaceId = registerJoinedCloudWorkspace(
16220
+ ctx.configPath,
16221
+ configYamlPath,
16222
+ credentialLabel,
16223
+ reg.team.name
16224
+ );
16225
+ sendJson(res, {
16226
+ ok: true,
16227
+ team: reg.team,
16228
+ user: reg.user,
16229
+ credential_label: credentialLabel,
16230
+ config_path: configYamlPath,
16231
+ ...workspaceId ? { workspace_id: workspaceId } : {}
16232
+ });
16057
16233
  }
16058
16234
  async function handleLeave(res, ctx, teamId) {
16059
16235
  const conn = await getConnection(ctx, teamId);
@@ -16082,8 +16258,8 @@ async function handleLeave(res, ctx, teamId) {
16082
16258
  }
16083
16259
 
16084
16260
  // src/gui/userconfig-routes.ts
16085
- import { existsSync as existsSync16, readdirSync as readdirSync6 } from "fs";
16086
- import { basename as basename4, dirname as dirname7, join as join15 } from "path";
16261
+ import { existsSync as existsSync18, readdirSync as readdirSync7 } from "fs";
16262
+ import { basename as basename5, dirname as dirname9, join as join17 } from "path";
16087
16263
  async function upsertIdentityRow(db, identity) {
16088
16264
  const existing = await db.get("__lattice_user_identity", "singleton");
16089
16265
  const updated_at = (/* @__PURE__ */ new Date()).toISOString();
@@ -16103,18 +16279,18 @@ async function upsertIdentityRow(db, identity) {
16103
16279
  }
16104
16280
  }
16105
16281
  function listProjectConfigs(activeConfigPath) {
16106
- const dir = dirname7(activeConfigPath);
16282
+ const dir = dirname9(activeConfigPath);
16107
16283
  const out = [];
16108
- if (!existsSync16(dir)) return out;
16109
- for (const fname of readdirSync6(dir)) {
16284
+ if (!existsSync18(dir)) return out;
16285
+ for (const fname of readdirSync7(dir)) {
16110
16286
  if (!fname.endsWith(".yml") && !fname.endsWith(".yaml")) continue;
16111
- const full = join15(dir, fname);
16287
+ const full = join17(dir, fname);
16112
16288
  try {
16113
16289
  const parsed = parseConfigFile(full);
16114
16290
  out.push({
16115
16291
  path: full,
16116
16292
  name: fname.replace(/\.(ya?ml)$/, ""),
16117
- dbFile: basename4(parsed.dbPath)
16293
+ dbFile: basename5(parsed.dbPath)
16118
16294
  });
16119
16295
  } catch {
16120
16296
  }
@@ -16193,12 +16369,12 @@ async function dispatchUserConfigRoute(req, res, ctx) {
16193
16369
  }
16194
16370
 
16195
16371
  // src/gui/dbconfig-routes.ts
16196
- import { readFileSync as readFileSync12, writeFileSync as writeFileSync7 } from "fs";
16197
- import { basename as basename5, isAbsolute as isAbsolute2, relative as relative2, resolve as resolve5, sep as sep3 } from "path";
16372
+ import { readFileSync as readFileSync13, writeFileSync as writeFileSync7 } from "fs";
16373
+ import { basename as basename6, dirname as dirname10, isAbsolute as isAbsolute2, relative as relative2, resolve as resolve7, sep as sep3 } from "path";
16198
16374
  import { parseDocument } from "yaml";
16199
16375
 
16200
16376
  // src/framework/cloud-migration.ts
16201
- import { existsSync as existsSync17, renameSync as renameSync3, unlinkSync as unlinkSync4 } from "fs";
16377
+ import { existsSync as existsSync19, renameSync as renameSync3, unlinkSync as unlinkSync4 } from "fs";
16202
16378
 
16203
16379
  // src/framework/native-entities.ts
16204
16380
  var NATIVE_ENTITY_DEFS = {
@@ -16268,7 +16444,8 @@ var NATIVE_ENTITY_DEFS = {
16268
16444
  notes: {
16269
16445
  // A generic knowledge object: a free-form note with a title and body.
16270
16446
  // Ordinary, user-editable rows; `source_file_id` optionally points back at
16271
- // an originating `files` row.
16447
+ // an originating `files` row. Retained as native (1.16.3) because the
16448
+ // reference/source-organizer store uses it as the fallback organizer target.
16272
16449
  columns: {
16273
16450
  id: "TEXT PRIMARY KEY",
16274
16451
  title: "TEXT",
@@ -16410,14 +16587,14 @@ async function openTargetLatticeForMigration(configPath, targetUrl, encryptionKe
16410
16587
  return target;
16411
16588
  }
16412
16589
  function archiveLocalSqlite(dbPath) {
16413
- if (!existsSync17(dbPath)) {
16590
+ if (!existsSync19(dbPath)) {
16414
16591
  throw new Error(`archiveLocalSqlite: source file does not exist: ${dbPath}`);
16415
16592
  }
16416
16593
  const backupPath = `${dbPath}.local-bak`;
16417
16594
  const siblings = ["", "-shm", "-wal"];
16418
16595
  for (const suffix of siblings) {
16419
16596
  const stale = `${dbPath}.local-bak${suffix}`;
16420
- if (existsSync17(stale)) {
16597
+ if (existsSync19(stale)) {
16421
16598
  try {
16422
16599
  unlinkSync4(stale);
16423
16600
  } catch {
@@ -16426,7 +16603,7 @@ function archiveLocalSqlite(dbPath) {
16426
16603
  }
16427
16604
  for (const suffix of siblings) {
16428
16605
  const src = `${dbPath}${suffix}`;
16429
- if (!existsSync17(src)) continue;
16606
+ if (!existsSync19(src)) continue;
16430
16607
  const dest = `${dbPath}.local-bak${suffix}`;
16431
16608
  renameSync3(src, dest);
16432
16609
  }
@@ -16434,6 +16611,17 @@ function archiveLocalSqlite(dbPath) {
16434
16611
  }
16435
16612
 
16436
16613
  // src/gui/dbconfig-routes.ts
16614
+ function updateActiveWorkspaceToCloud(configPath, label) {
16615
+ const root = findLatticeRoot(dirname10(configPath));
16616
+ if (!root) return;
16617
+ registerOrUpdateCloudWorkspace(root, {
16618
+ configPath,
16619
+ contextDir: resolveContextDirForConfig(configPath),
16620
+ displayName: label,
16621
+ db: "${LATTICE_DB:" + label + "}",
16622
+ makeActive: true
16623
+ });
16624
+ }
16437
16625
  function buildPostgresUrl(params) {
16438
16626
  const u = encodeURIComponent(params.user);
16439
16627
  const p = encodeURIComponent(params.password);
@@ -16464,7 +16652,7 @@ async function getCreatorEmail(db) {
16464
16652
  }
16465
16653
  function computeState(type, teamEnabled, label, creatorEmail) {
16466
16654
  if (type === "sqlite") return "local";
16467
- if (!teamEnabled) return "cloud-connected";
16655
+ if (!teamEnabled) return "team-cloud-needs-invite";
16468
16656
  if (!label) {
16469
16657
  return "team-cloud-needs-invite";
16470
16658
  }
@@ -16486,7 +16674,7 @@ function applyTeamMembershipState(info, membership) {
16486
16674
  return membership.joined ? membership.isCreator ? "team-cloud-creator" : "team-cloud-member" : "team-cloud-needs-invite";
16487
16675
  }
16488
16676
  async function describeCurrent(configPath, db) {
16489
- const rawYaml = readFileSync12(configPath, "utf8");
16677
+ const rawYaml = readFileSync13(configPath, "utf8");
16490
16678
  const doc = parseDocument(rawYaml);
16491
16679
  const rawDb = doc.get("db");
16492
16680
  const dbLine = typeof rawDb === "string" ? rawDb.trim() : "";
@@ -16552,7 +16740,7 @@ async function describeCurrent(configPath, db) {
16552
16740
  return {
16553
16741
  type: "sqlite",
16554
16742
  state: "local",
16555
- dbFile: basename5(dbLine),
16743
+ dbFile: basename6(dbLine),
16556
16744
  teamEnabled
16557
16745
  };
16558
16746
  }
@@ -16565,7 +16753,7 @@ async function detectTeamEnabled(db) {
16565
16753
  }
16566
16754
  }
16567
16755
  function rewriteDbLine(configPath, newValue) {
16568
- const doc = parseDocument(readFileSync12(configPath, "utf8"));
16756
+ const doc = parseDocument(readFileSync13(configPath, "utf8"));
16569
16757
  doc.set("db", newValue);
16570
16758
  writeFileSync7(configPath, doc.toString(), "utf8");
16571
16759
  }
@@ -16590,7 +16778,7 @@ function parseSaveBody(body) {
16590
16778
  return null;
16591
16779
  }
16592
16780
  function resolveRelativeToConfig(configPath, candidate) {
16593
- return isAbsolute2(candidate) ? candidate : resolve5(configPath, "..", candidate);
16781
+ return isAbsolute2(candidate) ? candidate : resolve7(configPath, "..", candidate);
16594
16782
  }
16595
16783
  async function dispatchDbConfigRoute(req, res, ctx) {
16596
16784
  const { pathname, method } = ctx;
@@ -16633,7 +16821,7 @@ async function dispatchDbConfigRoute(req, res, ctx) {
16633
16821
  return;
16634
16822
  }
16635
16823
  const abs = resolveRelativeToConfig(ctx.configPath, parsed.path);
16636
- const rel = relative2(resolve5(ctx.configPath, ".."), abs);
16824
+ const rel = relative2(resolve7(ctx.configPath, ".."), abs);
16637
16825
  const dbLine = rel.startsWith("..") ? abs : "./" + rel.split(sep3).join("/");
16638
16826
  rewriteDbLine(ctx.configPath, dbLine);
16639
16827
  sendJson(res, { ok: true, type: "sqlite", path: dbLine });
@@ -16744,7 +16932,21 @@ async function dispatchDbConfigRoute(req, res, ctx) {
16744
16932
  const sourceDbPath = parseConfigFile(ctx.configPath).dbPath;
16745
16933
  const backupPath = archiveLocalSqlite(sourceDbPath);
16746
16934
  saveDbCredential(parsed.label, url);
16935
+ try {
16936
+ const identity = readIdentity();
16937
+ if (identity.email) {
16938
+ await new TeamsClient(ctx.db).ensureCloudWorkspaceIdentity({
16939
+ label: parsed.label,
16940
+ cloudUrl: url,
16941
+ workspaceName: parsed.label,
16942
+ email: identity.email,
16943
+ displayName: identity.display_name
16944
+ });
16945
+ }
16946
+ } catch {
16947
+ }
16747
16948
  rewriteDbLine(ctx.configPath, "${LATTICE_DB:" + parsed.label + "}");
16949
+ updateActiveWorkspaceToCloud(ctx.configPath, parsed.label);
16748
16950
  await ctx.swap();
16749
16951
  sendJson(res, {
16750
16952
  ok: true,
@@ -16789,7 +16991,20 @@ async function dispatchDbConfigRoute(req, res, ctx) {
16789
16991
  ...identity.email ? { email: identity.email } : {},
16790
16992
  ...identity.display_name ? { name: identity.display_name } : {}
16791
16993
  });
16994
+ if (!result.probe.teamEnabled && identity.email) {
16995
+ try {
16996
+ await client.ensureCloudWorkspaceIdentity({
16997
+ label: parsed.label,
16998
+ cloudUrl: url,
16999
+ workspaceName: parsed.label,
17000
+ email: identity.email,
17001
+ displayName: identity.display_name
17002
+ });
17003
+ } catch {
17004
+ }
17005
+ }
16792
17006
  rewriteDbLine(ctx.configPath, "${LATTICE_DB:" + parsed.label + "}");
17007
+ updateActiveWorkspaceToCloud(ctx.configPath, parsed.label);
16793
17008
  await ctx.swap();
16794
17009
  sendJson(res, {
16795
17010
  ok: true,
@@ -16833,6 +17048,10 @@ async function dispatchDbConfigRoute(req, res, ctx) {
16833
17048
  team_name: name,
16834
17049
  updated_at: updatedAt
16835
17050
  });
17051
+ {
17052
+ const root = findLatticeRoot(dirname10(ctx.configPath));
17053
+ if (root) renameWorkspaceByConfigPath(root, ctx.configPath, name);
17054
+ }
16836
17055
  try {
16837
17056
  const teams = await ctx.db.query("__lattice_team", { limit: 1 });
16838
17057
  if (teams[0]) {
@@ -16846,67 +17065,14 @@ async function dispatchDbConfigRoute(req, res, ctx) {
16846
17065
  sendJson(res, { ok: true, kind: "cloud", name });
16847
17066
  return;
16848
17067
  }
16849
- const doc = parseDocument(readFileSync12(ctx.configPath, "utf8"));
17068
+ const doc = parseDocument(readFileSync13(ctx.configPath, "utf8"));
16850
17069
  doc.set("name", name);
16851
17070
  writeFileSync7(ctx.configPath, doc.toString(), "utf8");
16852
- sendJson(res, { ok: true, kind: "local", name });
16853
- });
16854
- return true;
16855
- }
16856
- if (pathname === "/api/dbconfig/upgrade-to-team" && method === "POST") {
16857
- await tryHandler(res, async () => {
16858
- const body = await readJson(req);
16859
- const teamName = typeof body.team_name === "string" && body.team_name.trim() ? body.team_name.trim() : "";
16860
- if (!teamName) {
16861
- sendJson(res, { error: "team_name is required" }, 400);
16862
- return;
16863
- }
16864
- const info = await describeCurrent(ctx.configPath, ctx.db);
16865
- if (info.type !== "postgres" || !info.label) {
16866
- sendJson(
16867
- res,
16868
- {
16869
- error: "upgrade-to-team requires the active project to be on a labeled cloud DB. Migrate to cloud first."
16870
- },
16871
- 400
16872
- );
16873
- return;
16874
- }
16875
- if (info.teamEnabled) {
16876
- sendJson(res, { error: "Cloud DB is already a team DB" }, 409);
16877
- return;
16878
- }
16879
- const cloudUrl = getDbCredential(info.label);
16880
- if (!cloudUrl) {
16881
- sendJson(res, { error: "No saved credential for " + info.label }, 500);
16882
- return;
16883
- }
16884
- const identity = readIdentity();
16885
- if (!identity.email || !identity.display_name) {
16886
- sendJson(
16887
- res,
16888
- {
16889
- error: "Set your display name + email in User Config \u2192 Identity before creating a team"
16890
- },
16891
- 400
16892
- );
16893
- return;
16894
- }
16895
- const client = new TeamsClient(ctx.db);
16896
- try {
16897
- const reg = await client.upgradeToTeamCloud({
16898
- label: info.label,
16899
- cloudUrl,
16900
- teamName,
16901
- email: identity.email,
16902
- displayName: identity.display_name
16903
- });
16904
- await ctx.swap();
16905
- sendJson(res, { ok: true, team: reg.team, user: reg.user });
16906
- } catch (e) {
16907
- const status = e.status ?? 500;
16908
- sendJson(res, { ok: false, error: e.message }, status);
17071
+ {
17072
+ const root = findLatticeRoot(dirname10(ctx.configPath));
17073
+ if (root) renameWorkspaceByConfigPath(root, ctx.configPath, name);
16909
17074
  }
17075
+ sendJson(res, { ok: true, kind: "local", name });
16910
17076
  });
16911
17077
  return true;
16912
17078
  }
@@ -17218,16 +17384,16 @@ function buildRowContextLocator(table, row, schemaDef, manifest) {
17218
17384
  }
17219
17385
  function readRowContext(outputDir, locator, secretCols) {
17220
17386
  const { slug, directoryRoot, fileNames } = locator;
17221
- const entityDir = resolve6(outputDir, directoryRoot, slug);
17222
- const resolvedBase = resolve6(outputDir);
17387
+ const entityDir = resolve8(outputDir, directoryRoot, slug);
17388
+ const resolvedBase = resolve8(outputDir);
17223
17389
  if (entityDir !== resolvedBase && !entityDir.startsWith(resolvedBase + sep4)) {
17224
17390
  throw new Error(`Path traversal detected: slug "${slug}" escapes output directory`);
17225
17391
  }
17226
17392
  return fileNames.map((filename) => {
17227
- const absPath = join16(entityDir, filename);
17393
+ const absPath = join18(entityDir, filename);
17228
17394
  const relPath = [directoryRoot, slug, filename].join("/");
17229
- if (!existsSync18(absPath)) return { name: filename, path: relPath, content: "" };
17230
- let content = readFileSync13(absPath, "utf8");
17395
+ if (!existsSync20(absPath)) return { name: filename, path: relPath, content: "" };
17396
+ let content = readFileSync14(absPath, "utf8");
17231
17397
  for (const col of secretCols) {
17232
17398
  const re = new RegExp(`^(${col}):.*$`, "gm");
17233
17399
  content = content.replace(re, `$1: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022`);
@@ -17236,17 +17402,17 @@ function readRowContext(outputDir, locator, secretCols) {
17236
17402
  });
17237
17403
  }
17238
17404
  function resolveOutputDirForConfig(configPath) {
17239
- const base = dirname8(resolve6(configPath));
17405
+ const base = dirname11(resolve8(configPath));
17240
17406
  for (const dir of ["context", ".", "generated"]) {
17241
- const abs = resolve6(base, dir);
17242
- if (existsSync18(join16(abs, ".lattice", "manifest.json"))) return abs;
17407
+ const abs = resolve8(base, dir);
17408
+ if (existsSync20(join18(abs, ".lattice", "manifest.json"))) return abs;
17243
17409
  }
17244
- return resolve6(base, "context");
17410
+ return resolve8(base, "context");
17245
17411
  }
17246
17412
  async function openConfig(configPath, outputDir, autoRender = false) {
17247
17413
  const parsed = parseConfigFile(configPath);
17248
17414
  if (!/^postgres(ql)?:\/\//i.test(parsed.dbPath) && !parsed.dbPath.startsWith("file:") && parsed.dbPath !== ":memory:") {
17249
- mkdirSync8(dirname8(parsed.dbPath), { recursive: true });
17415
+ mkdirSync9(dirname11(parsed.dbPath), { recursive: true });
17250
17416
  }
17251
17417
  const encryptionKey = getOrCreateMasterKey();
17252
17418
  const db = new Lattice({ config: configPath }, { encryptionKey });
@@ -17362,6 +17528,14 @@ async function openConfig(configPath, outputDir, autoRender = false) {
17362
17528
  } catch (e) {
17363
17529
  console.warn("[openConfig] could not enumerate team connections:", e.message);
17364
17530
  }
17531
+ for (const name of db.getRegisteredTableNames()) {
17532
+ if (name.startsWith("__lattice_") || name.startsWith("_lattice_")) continue;
17533
+ validTables.add(name);
17534
+ if (!softDeletable.has(name)) {
17535
+ const sharedCols = db.getRegisteredColumns(name);
17536
+ if (sharedCols && "deleted_at" in sharedCols) softDeletable.add(name);
17537
+ }
17538
+ }
17365
17539
  let teamContext = null;
17366
17540
  if (db.getDialect() === "postgres") {
17367
17541
  let teamEnabled = false;
@@ -17370,6 +17544,30 @@ async function openConfig(configPath, outputDir, autoRender = false) {
17370
17544
  } catch {
17371
17545
  teamEnabled = false;
17372
17546
  }
17547
+ if (!teamEnabled) {
17548
+ try {
17549
+ const rawDb = parseDocument2(readFileSync14(configPath, "utf8")).get("db");
17550
+ const dbLine = typeof rawDb === "string" ? rawDb.trim() : "";
17551
+ const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(dbLine);
17552
+ const label = labelMatch?.[1];
17553
+ const identity = readIdentity();
17554
+ if (label && identity.email) {
17555
+ await teamsClient.ensureCloudWorkspaceIdentity({
17556
+ label,
17557
+ cloudUrl: parsed.dbPath,
17558
+ workspaceName: label,
17559
+ email: identity.email,
17560
+ displayName: identity.display_name
17561
+ });
17562
+ teamEnabled = await db.get("__lattice_team_identity", "singleton") != null;
17563
+ }
17564
+ } catch (e) {
17565
+ console.warn(
17566
+ "[openConfig] could not auto-initialize cloud workspace:",
17567
+ e.message
17568
+ );
17569
+ }
17570
+ }
17373
17571
  if (teamEnabled) {
17374
17572
  await registerTeamCloudTables(db);
17375
17573
  try {
@@ -17412,7 +17610,7 @@ async function openConfig(configPath, outputDir, autoRender = false) {
17412
17610
  } catch (e) {
17413
17611
  console.warn("[openConfig] initial render failed:", e.message);
17414
17612
  }
17415
- if (!existsSync18(manifestPath(outputDir))) {
17613
+ if (!existsSync20(manifestPath(outputDir))) {
17416
17614
  writeManifest(outputDir, {
17417
17615
  version: 2,
17418
17616
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -17438,14 +17636,14 @@ async function openConfig(configPath, outputDir, autoRender = false) {
17438
17636
  }
17439
17637
  function friendlyConfigName(parsedName, configPath) {
17440
17638
  if (parsedName && parsedName.trim().length > 0) return parsedName.trim();
17441
- return basename6(configPath).replace(/\.(ya?ml)$/, "");
17639
+ return basename7(configPath).replace(/\.(ya?ml)$/, "");
17442
17640
  }
17443
17641
  function listConfigs(activeConfigPath) {
17444
- const dir = dirname8(activeConfigPath);
17642
+ const dir = dirname11(activeConfigPath);
17445
17643
  const entries = [];
17446
- for (const fname of readdirSync7(dir)) {
17644
+ for (const fname of readdirSync8(dir)) {
17447
17645
  if (!fname.endsWith(".yml") && !fname.endsWith(".yaml")) continue;
17448
- const full = join16(dir, fname);
17646
+ const full = join18(dir, fname);
17449
17647
  try {
17450
17648
  const parsed = parseConfigFile(full);
17451
17649
  entries.push({
@@ -17456,7 +17654,7 @@ function listConfigs(activeConfigPath) {
17456
17654
  // `label` is the friendly DB name — what the user sees in the
17457
17655
  // dropdown + settings. Falls back to the basename when unset.
17458
17656
  label: friendlyConfigName(parsed.name, full),
17459
- dbFile: basename6(parsed.dbPath),
17657
+ dbFile: basename7(parsed.dbPath),
17460
17658
  active: full === activeConfigPath,
17461
17659
  // `${LATTICE_DB:...}` and postgres:// configs resolve to a
17462
17660
  // postgres URL; everything else is a local SQLite file. This
@@ -17475,53 +17673,46 @@ async function execSql(db, sql) {
17475
17673
  await adapter.runAsync(sql);
17476
17674
  }
17477
17675
  function loadConfigDoc(configPath) {
17478
- return parseDocument2(readFileSync13(configPath, "utf8"));
17676
+ return parseDocument2(readFileSync14(configPath, "utf8"));
17479
17677
  }
17480
17678
  function saveConfigDoc(configPath, doc) {
17481
17679
  writeFileSync8(configPath, doc.toString(), "utf8");
17482
17680
  }
17483
17681
  function createBlankConfig(activeConfigPath, dbName) {
17484
- const dir = dirname8(activeConfigPath);
17682
+ const dir = dirname11(activeConfigPath);
17485
17683
  const slug = dbName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
17486
- if (!slug) throw new Error("Database name must contain at least one alphanumeric character");
17487
- const configPath = join16(dir, `${slug}.config.yml`);
17488
- if (existsSync18(configPath)) throw new Error(`Config already exists: ${slug}.config.yml`);
17684
+ if (!slug) throw new Error("Workspace name must contain at least one alphanumeric character");
17685
+ const configPath = join18(dir, `${slug}.config.yml`);
17686
+ if (existsSync20(configPath)) throw new Error(`Config already exists: ${slug}.config.yml`);
17489
17687
  const yaml = `db: ./data/${slug}.db
17490
17688
 
17491
- entities:
17492
- items:
17493
- fields:
17494
- id: { type: uuid, primaryKey: true }
17495
- name: { type: text, required: true }
17496
- notes: { type: text }
17497
- deleted_at: { type: text }
17498
- outputFile: ITEMS.md
17689
+ entities: {}
17499
17690
  `;
17500
17691
  writeFileSync8(configPath, yaml, "utf8");
17501
- mkdirSync8(join16(dir, "data"), { recursive: true });
17692
+ mkdirSync9(join18(dir, "data"), { recursive: true });
17502
17693
  return configPath;
17503
17694
  }
17504
17695
  function sqliteFileForConfig(configPath) {
17505
- const dbVal = parseDocument2(readFileSync13(configPath, "utf8")).get("db");
17696
+ const dbVal = parseDocument2(readFileSync14(configPath, "utf8")).get("db");
17506
17697
  const raw = (typeof dbVal === "string" ? dbVal : "").trim();
17507
17698
  if (!raw) return null;
17508
17699
  if (isPostgresUrl(raw) || raw.startsWith("${LATTICE_DB:")) return null;
17509
17700
  if (raw === ":memory:" || raw.startsWith("file:")) return null;
17510
- return resolve6(dirname8(configPath), raw);
17701
+ return resolve8(dirname11(configPath), raw);
17511
17702
  }
17512
17703
  function deleteDatabaseFiles(targetConfigPath) {
17513
17704
  const sqliteFile = sqliteFileForConfig(targetConfigPath);
17514
17705
  unlinkSync5(targetConfigPath);
17515
17706
  let deletedDbFile = null;
17516
- if (sqliteFile && existsSync18(sqliteFile)) {
17707
+ if (sqliteFile && existsSync20(sqliteFile)) {
17517
17708
  unlinkSync5(sqliteFile);
17518
17709
  deletedDbFile = sqliteFile;
17519
17710
  for (const suffix of ["-wal", "-shm", "-journal"]) {
17520
17711
  const sidecar = sqliteFile + suffix;
17521
- if (existsSync18(sidecar)) unlinkSync5(sidecar);
17712
+ if (existsSync20(sidecar)) unlinkSync5(sidecar);
17522
17713
  }
17523
17714
  }
17524
- return { deletedConfig: basename6(targetConfigPath), deletedDbFile };
17715
+ return { deletedConfig: basename7(targetConfigPath), deletedDbFile };
17525
17716
  }
17526
17717
  async function disposeActive(active) {
17527
17718
  if (active.realtime) {
@@ -17703,15 +17894,15 @@ function schemaReverseSummary(verb, entry) {
17703
17894
  return `${verb} schema change (${what}) on ${entry.table_name}`;
17704
17895
  }
17705
17896
  async function startGuiServer(options) {
17706
- const configPath = resolve6(options.configPath);
17707
- const outputDir = resolve6(options.outputDir);
17897
+ const configPath = resolve8(options.configPath);
17898
+ const outputDir = resolve8(options.outputDir);
17708
17899
  const startPort = options.port ?? 4317;
17709
17900
  const host = options.host ?? "127.0.0.1";
17710
17901
  const teamCloud = options.teamCloud ?? false;
17711
17902
  const autoRender = options.autoRender ?? false;
17712
17903
  const sessionId = crypto.randomUUID();
17713
17904
  let active = await openConfig(configPath, outputDir, autoRender);
17714
- const latticeRoot = findLatticeRoot(dirname8(configPath));
17905
+ const latticeRoot = findLatticeRoot(dirname11(configPath));
17715
17906
  if (teamCloud) {
17716
17907
  await registerTeamCloudTables(active.db);
17717
17908
  }
@@ -18929,6 +19120,98 @@ data: ${JSON.stringify(data)}
18929
19120
  sendJson(res, { ok: true, id: created.id });
18930
19121
  return;
18931
19122
  }
19123
+ if (method === "POST" && pathname === "/api/workspaces/delete") {
19124
+ if (teamCloud) {
19125
+ sendJson(res, { error: "Workspace deletion is disabled in team-cloud mode" }, 403);
19126
+ return;
19127
+ }
19128
+ if (!latticeRoot) {
19129
+ sendJson(res, { error: "No .lattice root \u2014 workspaces unavailable" }, 400);
19130
+ return;
19131
+ }
19132
+ const body = await readJson(req);
19133
+ if (typeof body.id !== "string") {
19134
+ sendJson(res, { error: "id must be a string" }, 400);
19135
+ return;
19136
+ }
19137
+ const ws = getWorkspace(latticeRoot, body.id);
19138
+ if (!ws) {
19139
+ sendJson(res, { error: `No workspace with id ${body.id}` }, 400);
19140
+ return;
19141
+ }
19142
+ const wsPaths = resolveWorkspacePaths(latticeRoot, ws);
19143
+ const isActive = resolve8(active.configPath) === resolve8(wsPaths.configPath);
19144
+ if (isActive && active.teamContext && !active.teamContext.isCreator) {
19145
+ sendJson(res, { error: "Only the team owner can delete this cloud workspace" }, 403);
19146
+ return;
19147
+ }
19148
+ let switchedTo = null;
19149
+ if (isActive) {
19150
+ const fallback = listWorkspaces(latticeRoot).find((w) => w.id !== ws.id);
19151
+ if (!fallback) {
19152
+ sendJson(
19153
+ res,
19154
+ {
19155
+ error: "Cannot delete the only workspace. Create or add another workspace first, then delete this one."
19156
+ },
19157
+ 400
19158
+ );
19159
+ return;
19160
+ }
19161
+ const fbPaths = resolveWorkspacePaths(latticeRoot, fallback);
19162
+ let next;
19163
+ try {
19164
+ next = await openConfig(fbPaths.configPath, fbPaths.contextDir, autoRender);
19165
+ } catch (e) {
19166
+ const err = e;
19167
+ const codePrefix = err.code ? `[${err.code}] ` : "";
19168
+ sendJson(
19169
+ res,
19170
+ {
19171
+ error: `Cannot delete: failed to switch to ${fallback.displayName} first: ${codePrefix}${err.message}`
19172
+ },
19173
+ 500
19174
+ );
19175
+ return;
19176
+ }
19177
+ setActiveWorkspace(latticeRoot, fallback.id);
19178
+ await disposeActive(active);
19179
+ active = next;
19180
+ switchedTo = fallback.id;
19181
+ }
19182
+ removeWorkspace(latticeRoot, ws.id);
19183
+ try {
19184
+ if (!ws.configPath && ws.kind === "local") {
19185
+ rmSync2(workspaceDir(latticeRoot, ws.dir), { recursive: true, force: true });
19186
+ } else if (ws.kind === "cloud") {
19187
+ if (ws.configPath && existsSync20(ws.configPath)) {
19188
+ rmSync2(ws.configPath, { force: true });
19189
+ }
19190
+ const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
19191
+ const label = labelMatch?.[1];
19192
+ if (label) {
19193
+ const stillUsed = listWorkspaces(latticeRoot).some(
19194
+ (w) => w.db.includes("${LATTICE_DB:" + label + "}")
19195
+ );
19196
+ if (!stillUsed) {
19197
+ try {
19198
+ deleteDbCredential(label);
19199
+ } catch {
19200
+ }
19201
+ }
19202
+ }
19203
+ }
19204
+ } catch (e) {
19205
+ sendJson(
19206
+ res,
19207
+ { error: `Workspace unregistered but file cleanup failed: ${e.message}` },
19208
+ 500
19209
+ );
19210
+ return;
19211
+ }
19212
+ sendJson(res, { ok: true, switchedTo });
19213
+ return;
19214
+ }
18932
19215
  if (teamCloud && pathname.startsWith("/api/databases")) {
18933
19216
  sendJson(res, { error: "Database switching is disabled in team-cloud mode" }, 403);
18934
19217
  return;
@@ -18948,7 +19231,7 @@ data: ${JSON.stringify(data)}
18948
19231
  sendJson(res, {
18949
19232
  current: {
18950
19233
  path: active.configPath,
18951
- dbFile: basename6(parsedActive.dbPath),
19234
+ dbFile: basename7(parsedActive.dbPath),
18952
19235
  label: friendlyLabel,
18953
19236
  kind
18954
19237
  },
@@ -18962,8 +19245,8 @@ data: ${JSON.stringify(data)}
18962
19245
  sendJson(res, { error: "path must be a string" }, 400);
18963
19246
  return;
18964
19247
  }
18965
- const newPath = resolve6(body.path);
18966
- if (!existsSync18(newPath)) {
19248
+ const newPath = resolve8(body.path);
19249
+ if (!existsSync20(newPath)) {
18967
19250
  sendJson(res, { error: `Config not found: ${newPath}` }, 400);
18968
19251
  return;
18969
19252
  }
@@ -19013,16 +19296,16 @@ data: ${JSON.stringify(data)}
19013
19296
  sendJson(res, { error: "path must be a non-empty string" }, 400);
19014
19297
  return;
19015
19298
  }
19016
- const target = resolve6(body.path);
19299
+ const target = resolve8(body.path);
19017
19300
  const known = listConfigs(active.configPath);
19018
- const match = known.find((c) => resolve6(c.path) === target);
19301
+ const match = known.find((c) => resolve8(c.path) === target);
19019
19302
  if (!match) {
19020
19303
  sendJson(res, { error: `Not a known database config: ${target}` }, 400);
19021
19304
  return;
19022
19305
  }
19023
19306
  let switchedTo = null;
19024
- if (resolve6(active.configPath) === target) {
19025
- const fallback = known.find((c) => resolve6(c.path) !== target);
19307
+ if (resolve8(active.configPath) === target) {
19308
+ const fallback = known.find((c) => resolve8(c.path) !== target);
19026
19309
  if (!fallback) {
19027
19310
  sendJson(
19028
19311
  res,
@@ -19410,20 +19693,8 @@ data: ${JSON.stringify(data)}
19410
19693
  };
19411
19694
  }
19412
19695
 
19413
- // src/gui/discover-output-dir.ts
19414
- import { existsSync as existsSync19 } from "fs";
19415
- import { join as join17, resolve as resolve7 } from "path";
19416
- function discoverOutputDir(explicitOutput, explicit) {
19417
- if (explicit) return explicitOutput;
19418
- const candidates = ["./context", ".", "./generated"];
19419
- for (const dir of candidates) {
19420
- if (existsSync19(join17(resolve7(dir), ".lattice", "manifest.json"))) return dir;
19421
- }
19422
- return explicitOutput;
19423
- }
19424
-
19425
19696
  // src/teams/cli-commands.ts
19426
- import { resolve as resolve8 } from "path";
19697
+ import { resolve as resolve9 } from "path";
19427
19698
  var TEAMS_USAGE = [
19428
19699
  "lattice teams <subcommand> [options]",
19429
19700
  "",
@@ -19544,7 +19815,7 @@ function requireArg(args, key, label) {
19544
19815
  return v.trim();
19545
19816
  }
19546
19817
  async function openLocal(configPath) {
19547
- const db = new Lattice({ config: resolve8(configPath) });
19818
+ const db = new Lattice({ config: resolve9(configPath) });
19548
19819
  await db.init();
19549
19820
  return db;
19550
19821
  }
@@ -19907,34 +20178,6 @@ async function runDlq(args) {
19907
20178
  }
19908
20179
  }
19909
20180
 
19910
- // src/framework/migrate-to-root.ts
19911
- import { cpSync, existsSync as existsSync20, mkdirSync as mkdirSync9 } from "fs";
19912
- import { homedir as homedir3 } from "os";
19913
- import { join as join18 } from "path";
19914
- var LEGACY_ENTRIES = [
19915
- "master.key",
19916
- "identity.json",
19917
- "preferences.json",
19918
- "db-credentials.enc",
19919
- "keys"
19920
- ];
19921
- function importLegacyUserConfig(root) {
19922
- const legacy = process.env.LATTICE_CONFIG_DIR ?? join18(homedir3(), ".lattice");
19923
- const dest = rootConfigDir(root);
19924
- const copied = [];
19925
- if (!existsSync20(join18(legacy, "master.key"))) return { migrated: false, copied };
19926
- if (existsSync20(join18(dest, "master.key"))) return { migrated: false, copied };
19927
- mkdirSync9(dest, { recursive: true });
19928
- for (const entry of LEGACY_ENTRIES) {
19929
- const src = join18(legacy, entry);
19930
- if (existsSync20(src)) {
19931
- cpSync(src, join18(dest, entry), { recursive: true });
19932
- copied.push(entry);
19933
- }
19934
- }
19935
- return copied.length > 0 ? { migrated: true, from: legacy, copied } : { migrated: false, copied };
19936
- }
19937
-
19938
20181
  // src/cli.ts
19939
20182
  function parseArgs(argv) {
19940
20183
  let command;
@@ -20189,7 +20432,7 @@ function printHelp() {
20189
20432
  function getVersion() {
20190
20433
  try {
20191
20434
  const pkgPath = new URL("../package.json", import.meta.url).pathname;
20192
- const pkg = JSON.parse(readFileSync14(pkgPath, "utf-8"));
20435
+ const pkg = JSON.parse(readFileSync15(pkgPath, "utf-8"));
20193
20436
  return pkg.version;
20194
20437
  } catch {
20195
20438
  return "unknown";
@@ -20220,10 +20463,10 @@ async function runUpdate() {
20220
20463
  }
20221
20464
  }
20222
20465
  function runGenerate(args) {
20223
- const configPath = resolve9(args.config);
20466
+ const configPath = resolve10(args.config);
20224
20467
  let raw;
20225
20468
  try {
20226
- raw = readFileSync14(configPath, "utf-8");
20469
+ raw = readFileSync15(configPath, "utf-8");
20227
20470
  } catch {
20228
20471
  console.error(`Error: cannot read config file at "${configPath}"`);
20229
20472
  process.exit(1);
@@ -20239,8 +20482,8 @@ function runGenerate(args) {
20239
20482
  console.error('Error: config must have an "entities" key');
20240
20483
  process.exit(1);
20241
20484
  }
20242
- const configDir2 = dirname9(configPath);
20243
- const outDir = resolve9(args.out);
20485
+ const configDir2 = dirname12(configPath);
20486
+ const outDir = resolve10(args.out);
20244
20487
  try {
20245
20488
  const result = generateAll({ config, configDir: configDir2, outDir, scaffold: args.scaffold });
20246
20489
  console.log(`Generated ${String(result.filesWritten.length)} file(s):`);
@@ -20253,15 +20496,15 @@ function runGenerate(args) {
20253
20496
  }
20254
20497
  }
20255
20498
  async function runRender(args) {
20256
- const outputDir = resolve9(args.output);
20499
+ const outputDir = resolve10(args.output);
20257
20500
  let parsed;
20258
20501
  try {
20259
- parsed = parseConfigFile(resolve9(args.config));
20502
+ parsed = parseConfigFile(resolve10(args.config));
20260
20503
  } catch (e) {
20261
20504
  console.error(`Error: ${e.message}`);
20262
20505
  process.exit(1);
20263
20506
  }
20264
- const db = new Lattice({ config: resolve9(args.config) });
20507
+ const db = new Lattice({ config: resolve10(args.config) });
20265
20508
  try {
20266
20509
  await db.init();
20267
20510
  const start = Date.now();
@@ -20280,8 +20523,8 @@ async function runRender(args) {
20280
20523
  void parsed;
20281
20524
  }
20282
20525
  async function runReconcile(args, isDryRun) {
20283
- const outputDir = resolve9(args.output);
20284
- const db = new Lattice({ config: resolve9(args.config) });
20526
+ const outputDir = resolve10(args.output);
20527
+ const db = new Lattice({ config: resolve10(args.config) });
20285
20528
  try {
20286
20529
  await db.init();
20287
20530
  const start = Date.now();
@@ -20340,8 +20583,8 @@ function formatTimestamp() {
20340
20583
  return `${hh}:${mm}:${ss}`;
20341
20584
  }
20342
20585
  async function runWatch(args) {
20343
- const outputDir = resolve9(args.output);
20344
- const db = new Lattice({ config: resolve9(args.config) });
20586
+ const outputDir = resolve10(args.output);
20587
+ const db = new Lattice({ config: resolve10(args.config) });
20345
20588
  try {
20346
20589
  await db.init();
20347
20590
  } catch (e) {
@@ -20382,32 +20625,19 @@ async function runWatch(args) {
20382
20625
  }
20383
20626
  async function runGui(args) {
20384
20627
  try {
20385
- let configPath = resolve9(args.config);
20386
- let outputDir;
20387
- let autoRender = false;
20388
- const root = findLatticeRoot(args.root ?? process.cwd());
20389
- const ws = root && args.config === "./lattice.config.yml" ? getActiveWorkspace(root) : null;
20390
- if (root && ws) {
20391
- const paths = resolveWorkspacePaths(root, ws);
20392
- configPath = paths.configPath;
20393
- outputDir = paths.contextDir;
20394
- autoRender = true;
20395
- console.log(`Lattice GUI: opening workspace "${ws.displayName}".`);
20396
- } else {
20397
- const resolvedOutput = discoverOutputDir(args.output, args.outputExplicit);
20398
- if (!args.outputExplicit && resolvedOutput !== args.output) {
20399
- console.log(
20400
- `Lattice GUI: auto-detected rendered context at "${resolvedOutput}" (use --output to override).`
20401
- );
20402
- }
20403
- outputDir = resolve9(resolvedOutput);
20404
- }
20628
+ if (args.root) process.env.LATTICE_ROOT = args.root;
20629
+ const boot = ensureRootForGui({
20630
+ startDir: args.root ?? process.cwd(),
20631
+ configPath: resolve10(args.config),
20632
+ explicitConfig: args.config !== "./lattice.config.yml"
20633
+ });
20634
+ console.log(`Lattice GUI: opening workspace "${boot.displayName}".`);
20405
20635
  const handle = await startGuiServer({
20406
- configPath,
20407
- outputDir,
20636
+ configPath: boot.configPath,
20637
+ outputDir: boot.contextDir,
20408
20638
  port: args.port,
20409
20639
  openBrowser: !args.noOpen,
20410
- autoRender
20640
+ autoRender: true
20411
20641
  });
20412
20642
  console.log(`Lattice GUI listening at ${handle.url}`);
20413
20643
  console.log("Press Ctrl+C to stop.");
@@ -20424,8 +20654,8 @@ async function runGui(args) {
20424
20654
  async function runServe(args) {
20425
20655
  try {
20426
20656
  const handle = await startGuiServer({
20427
- configPath: resolve9(args.config),
20428
- outputDir: resolve9(args.output),
20657
+ configPath: resolve10(args.config),
20658
+ outputDir: resolve10(args.output),
20429
20659
  host: args.host,
20430
20660
  port: args.port,
20431
20661
  openBrowser: false,