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/README.md +45 -32
- package/dist/cli.js +1011 -781
- package/dist/index.cjs +86 -5
- package/dist/index.d.cts +46 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +89 -8
- package/package.json +1 -1
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
|
|
11
|
-
import { readFileSync as
|
|
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
|
|
5895
|
-
mkdirSync as
|
|
5896
|
-
readFileSync as
|
|
5897
|
-
readdirSync as
|
|
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
|
|
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((
|
|
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
|
-
|
|
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
|
|
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 =
|
|
5955
|
-
const resolved =
|
|
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:
|
|
6005
|
-
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
|
-
|
|
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[
|
|
7342
|
-
state.systemTables = (results[
|
|
7343
|
-
state.preferences = results[
|
|
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
|
-
|
|
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
|
|
7653
|
-
|
|
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[
|
|
7954
|
-
state.systemTables = (results[
|
|
7955
|
-
|
|
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
|
-
|
|
7974
|
-
//
|
|
7975
|
-
//
|
|
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
|
-
|
|
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 ===
|
|
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:
|
|
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 ===
|
|
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
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
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
|
-
|
|
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>
|
|
8306
|
-
'<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
|
|
8483
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
9286
|
-
|
|
9287
|
-
|
|
9288
|
-
|
|
9289
|
-
|
|
9290
|
-
|
|
9291
|
-
|
|
9292
|
-
|
|
9293
|
-
|
|
9294
|
-
|
|
9295
|
-
|
|
9296
|
-
|
|
9297
|
-
|
|
9298
|
-
|
|
9299
|
-
|
|
9300
|
-
|
|
9301
|
-
var
|
|
9302
|
-
|
|
9303
|
-
|
|
9304
|
-
|
|
9305
|
-
|
|
9306
|
-
|
|
9307
|
-
|
|
9308
|
-
|
|
9309
|
-
|
|
9310
|
-
|
|
9311
|
-
|
|
9312
|
-
|
|
9313
|
-
|
|
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
|
-
//
|
|
9322
|
-
//
|
|
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
|
-
|
|
9335
|
-
|
|
9336
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
10273
|
-
//
|
|
10274
|
-
//
|
|
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>
|
|
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 ? '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
10919
|
-
//
|
|
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('
|
|
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('
|
|
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
|
|
10928
|
-
if (!wizState.displayName.trim()) { alert('Display name is required for cloud
|
|
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('
|
|
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
|
-
//
|
|
10967
|
-
//
|
|
10968
|
-
//
|
|
10969
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
11086
|
-
// header
|
|
11087
|
-
//
|
|
11088
|
-
var
|
|
11089
|
-
if (!
|
|
11090
|
-
|
|
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({
|
|
11125
|
+
body: JSON.stringify({ id: wsId }),
|
|
11094
11126
|
})
|
|
11095
11127
|
.then(function () { return reloadEverything(); })
|
|
11096
|
-
.then(function () { showToast('Joined "' + (res.team && res.team.name || '
|
|
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
|
|
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>
|
|
11262
|
-
'<div id="db-name-host"><div class="placeholder" style="padding:14px">Loading
|
|
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(
|
|
11277
|
-
var safeLabel = (label || '').trim() || 'this
|
|
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
|
|
11281
|
-
'For a cloud
|
|
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
|
|
11286
|
-
primaryLabel: 'Delete
|
|
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
|
|
11300
|
-
return fetch('/api/
|
|
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({
|
|
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/
|
|
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
|
|
11323
|
-
var
|
|
11324
|
-
var
|
|
11325
|
-
|
|
11326
|
-
|
|
11327
|
-
|
|
11328
|
-
|
|
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
|
|
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/
|
|
11307
|
+
? fetchJson('/api/workspaces/switch', {
|
|
11334
11308
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
11335
|
-
body: JSON.stringify({
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
11394
|
-
// We just deleted the active
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
|
11413
|
-
var
|
|
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
|
|
11412
|
+
? ('Friendly workspace name shown in the topbar and the dropdown. ' +
|
|
11432
11413
|
(isCloud
|
|
11433
|
-
? 'For cloud
|
|
11434
|
-
: 'Saved to the
|
|
11435
|
-
: 'Only the
|
|
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
|
|
11456
|
-
return fetchJson('/api/
|
|
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
|
|
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
|
|
11471
|
-
'<div id="lattice-dbs-host"><div class="placeholder" style="padding:18px">Loading
|
|
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
|
-
//
|
|
11475
|
-
|
|
11476
|
-
|
|
11477
|
-
var
|
|
11478
|
-
var rows =
|
|
11479
|
-
var
|
|
11480
|
-
|
|
11481
|
-
|
|
11482
|
-
|
|
11483
|
-
|
|
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(
|
|
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>
|
|
11499
|
-
'<tbody>' + (rows || '<tr><td colspan="
|
|
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-
|
|
11480
|
+
host.querySelectorAll('tr.ws-row[data-switch-id]').forEach(function (row) {
|
|
11503
11481
|
row.addEventListener('click', function () {
|
|
11504
|
-
var
|
|
11505
|
-
fetch('/api/
|
|
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
|
|
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}
|
|
11528
|
+
label = '\u{1F451} CLOUD \xB7 OWNER';
|
|
11571
11529
|
color = 'var(--accent)';
|
|
11572
11530
|
break;
|
|
11573
11531
|
case 'team-cloud-member':
|
|
11574
|
-
label = '
|
|
11532
|
+
label = 'CLOUD \xB7 MEMBER';
|
|
11575
11533
|
color = 'var(--accent)';
|
|
11576
11534
|
break;
|
|
11577
11535
|
case 'team-cloud-needs-invite':
|
|
11578
|
-
label = '
|
|
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
|
|
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
|
|
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>
|
|
11614
|
-
(
|
|
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
|
-
(
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
11691
|
-
|
|
11692
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
11955
|
-
|
|
11956
|
-
|
|
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
|
-
|
|
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) {
|
|
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="
|
|
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">
|
|
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((
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
15754
|
-
import { dirname as
|
|
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 =
|
|
15758
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
15783
|
-
const yamlPath =
|
|
15784
|
-
if (
|
|
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
|
-
|
|
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
|
|
16086
|
-
import { basename as
|
|
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 =
|
|
16282
|
+
const dir = dirname9(activeConfigPath);
|
|
16107
16283
|
const out = [];
|
|
16108
|
-
if (!
|
|
16109
|
-
for (const fname of
|
|
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 =
|
|
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:
|
|
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
|
|
16197
|
-
import { basename as
|
|
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
|
|
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 (!
|
|
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 (
|
|
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 (!
|
|
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-
|
|
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 =
|
|
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:
|
|
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(
|
|
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 :
|
|
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(
|
|
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(
|
|
17068
|
+
const doc = parseDocument(readFileSync13(ctx.configPath, "utf8"));
|
|
16850
17069
|
doc.set("name", name);
|
|
16851
17070
|
writeFileSync7(ctx.configPath, doc.toString(), "utf8");
|
|
16852
|
-
|
|
16853
|
-
|
|
16854
|
-
|
|
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 =
|
|
17222
|
-
const resolvedBase =
|
|
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 =
|
|
17393
|
+
const absPath = join18(entityDir, filename);
|
|
17228
17394
|
const relPath = [directoryRoot, slug, filename].join("/");
|
|
17229
|
-
if (!
|
|
17230
|
-
let content =
|
|
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 =
|
|
17405
|
+
const base = dirname11(resolve8(configPath));
|
|
17240
17406
|
for (const dir of ["context", ".", "generated"]) {
|
|
17241
|
-
const abs =
|
|
17242
|
-
if (
|
|
17407
|
+
const abs = resolve8(base, dir);
|
|
17408
|
+
if (existsSync20(join18(abs, ".lattice", "manifest.json"))) return abs;
|
|
17243
17409
|
}
|
|
17244
|
-
return
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
17639
|
+
return basename7(configPath).replace(/\.(ya?ml)$/, "");
|
|
17442
17640
|
}
|
|
17443
17641
|
function listConfigs(activeConfigPath) {
|
|
17444
|
-
const dir =
|
|
17642
|
+
const dir = dirname11(activeConfigPath);
|
|
17445
17643
|
const entries = [];
|
|
17446
|
-
for (const fname of
|
|
17644
|
+
for (const fname of readdirSync8(dir)) {
|
|
17447
17645
|
if (!fname.endsWith(".yml") && !fname.endsWith(".yaml")) continue;
|
|
17448
|
-
const full =
|
|
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:
|
|
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(
|
|
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 =
|
|
17682
|
+
const dir = dirname11(activeConfigPath);
|
|
17485
17683
|
const slug = dbName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
17486
|
-
if (!slug) throw new Error("
|
|
17487
|
-
const configPath =
|
|
17488
|
-
if (
|
|
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
|
-
|
|
17692
|
+
mkdirSync9(join18(dir, "data"), { recursive: true });
|
|
17502
17693
|
return configPath;
|
|
17503
17694
|
}
|
|
17504
17695
|
function sqliteFileForConfig(configPath) {
|
|
17505
|
-
const dbVal = parseDocument2(
|
|
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
|
|
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 &&
|
|
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 (
|
|
17712
|
+
if (existsSync20(sidecar)) unlinkSync5(sidecar);
|
|
17522
17713
|
}
|
|
17523
17714
|
}
|
|
17524
|
-
return { deletedConfig:
|
|
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 =
|
|
17707
|
-
const 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(
|
|
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:
|
|
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 =
|
|
18966
|
-
if (!
|
|
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 =
|
|
19299
|
+
const target = resolve8(body.path);
|
|
19017
19300
|
const known = listConfigs(active.configPath);
|
|
19018
|
-
const match = known.find((c) =>
|
|
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 (
|
|
19025
|
-
const fallback = known.find((c) =>
|
|
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
|
|
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:
|
|
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(
|
|
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 =
|
|
20466
|
+
const configPath = resolve10(args.config);
|
|
20224
20467
|
let raw;
|
|
20225
20468
|
try {
|
|
20226
|
-
raw =
|
|
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 =
|
|
20243
|
-
const outDir =
|
|
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 =
|
|
20499
|
+
const outputDir = resolve10(args.output);
|
|
20257
20500
|
let parsed;
|
|
20258
20501
|
try {
|
|
20259
|
-
parsed = parseConfigFile(
|
|
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:
|
|
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 =
|
|
20284
|
-
const db = new Lattice({ 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 =
|
|
20344
|
-
const db = new Lattice({ 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
|
-
|
|
20386
|
-
|
|
20387
|
-
|
|
20388
|
-
|
|
20389
|
-
|
|
20390
|
-
|
|
20391
|
-
|
|
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:
|
|
20428
|
-
outputDir:
|
|
20657
|
+
configPath: resolve10(args.config),
|
|
20658
|
+
outputDir: resolve10(args.output),
|
|
20429
20659
|
host: args.host,
|
|
20430
20660
|
port: args.port,
|
|
20431
20661
|
openBrowser: false,
|