@venturewild/workspace 0.4.0 → 0.4.1
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/package.json
CHANGED
package/server/src/index.mjs
CHANGED
|
@@ -65,8 +65,12 @@ import {
|
|
|
65
65
|
createWorkspace as registryCreateWorkspace,
|
|
66
66
|
touchWorkspace,
|
|
67
67
|
removeWorkspace,
|
|
68
|
+
markShared,
|
|
69
|
+
setSyncProject,
|
|
70
|
+
seedDefault,
|
|
68
71
|
workspaceIdForDir,
|
|
69
72
|
} from './workspace-registry.mjs';
|
|
73
|
+
import { createWorkspaceRails } from './workspaces.mjs';
|
|
70
74
|
import { matchCandidates } from './bazaar/mock-tickup.mjs';
|
|
71
75
|
import { servePreviewFile, confineBuildDir } from './bazaar/preview-server.mjs';
|
|
72
76
|
import { TURN_SYSTEM_PROMPT, writeTurnMcpConfig } from './turn-mcp.mjs';
|
|
@@ -334,6 +338,12 @@ export async function createServer(overrides = {}) {
|
|
|
334
338
|
// cache. `canvas` above still owns the workspace-SHARED agent content (blocks +
|
|
335
339
|
// agent theme). Person-scoped state stores are built lazily + cached per personKey.
|
|
336
340
|
const canvasRails = overrides.canvasRails || createCanvasRails(config, config.account);
|
|
341
|
+
// Shared-workspace membership client (sharing slice — design §4). The account
|
|
342
|
+
// token is kept top-level on config (out of the broadcast `config.account`), so
|
|
343
|
+
// pass it explicitly, mirroring the CLI `wild-workspace workspace …`. Inert (and
|
|
344
|
+
// every call returns {ok:false, code:'not_configured'}) until the owner logs in.
|
|
345
|
+
const workspaceRails =
|
|
346
|
+
overrides.workspaceRails || createWorkspaceRails(config, { accountToken: config.accountToken });
|
|
337
347
|
const canvasStores = new Map();
|
|
338
348
|
function canvasFor(personKey) {
|
|
339
349
|
let store = canvasStores.get(personKey);
|
|
@@ -1885,16 +1895,23 @@ export async function createServer(overrides = {}) {
|
|
|
1885
1895
|
const src = c.get('session')?.source;
|
|
1886
1896
|
return src === 'localhost' || src === 'loopback';
|
|
1887
1897
|
}
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1898
|
+
// The client-facing shape of a lobby card. Shared workspaces (sharing slice)
|
|
1899
|
+
// carry their rails identity + the local member's role so the lobby can badge
|
|
1900
|
+
// the card + gate owner-only actions (invite/un-share).
|
|
1901
|
+
function lobbyShape(w) {
|
|
1902
|
+
return {
|
|
1891
1903
|
id: w.id,
|
|
1892
1904
|
name: w.name,
|
|
1893
1905
|
dir: w.dir,
|
|
1894
1906
|
kind: w.kind,
|
|
1907
|
+
shared: w.kind === 'shared' && w.shared ? w.shared : undefined,
|
|
1895
1908
|
lastOpenedAt: w.lastOpenedAt,
|
|
1896
1909
|
isDefault: w.id === defaultWorkspace.id,
|
|
1897
|
-
}
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
function lobbyList() {
|
|
1913
|
+
const reg = listWorkspaces(registryEnv);
|
|
1914
|
+
const items = reg.map(lobbyShape);
|
|
1898
1915
|
// The boot/default workspace is computed (never persisted) — inject it if it
|
|
1899
1916
|
// isn't already an explicit registry entry, so the lobby is never blank.
|
|
1900
1917
|
if (!reg.some((w) => w.id === defaultWorkspace.id)) {
|
|
@@ -1910,10 +1927,41 @@ export async function createServer(overrides = {}) {
|
|
|
1910
1927
|
return items.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
|
|
1911
1928
|
}
|
|
1912
1929
|
|
|
1913
|
-
|
|
1930
|
+
// The shared workspaces THIS account belongs to, from the rails (sharing slice).
|
|
1931
|
+
// Degrade-never: not logged in / rails down → []; the lobby still shows local
|
|
1932
|
+
// workspaces. This is how a workspace someone shared with you APPEARS in your
|
|
1933
|
+
// lobby — before you've joined it, it has no local folder yet.
|
|
1934
|
+
async function sharedFromRails() {
|
|
1935
|
+
if (!config.account?.email || !workspaceRails.capable) return [];
|
|
1936
|
+
const r = await workspaceRails.list();
|
|
1937
|
+
if (!r.ok || !Array.isArray(r.data?.workspaces)) return [];
|
|
1938
|
+
return r.data.workspaces; // [{ slug, name, role, owner_email }]
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// Merge the local registry with the rails membership: shared workspaces I belong
|
|
1942
|
+
// to but haven't given a local home yet surface as `joinable` cards (no dir,
|
|
1943
|
+
// can't be entered until joined). Ones I already host locally are deduped by slug.
|
|
1944
|
+
async function lobbyListMerged() {
|
|
1945
|
+
const local = lobbyList();
|
|
1946
|
+
const localSlugs = new Set(local.filter((w) => w.shared?.slug).map((w) => w.shared.slug));
|
|
1947
|
+
const joinable = (await sharedFromRails())
|
|
1948
|
+
.filter((w) => !localSlugs.has(w.slug))
|
|
1949
|
+
.map((w) => ({
|
|
1950
|
+
id: `shared:${w.slug}`, // synthetic — no local folder to re-root into yet
|
|
1951
|
+
name: w.name,
|
|
1952
|
+
slug: w.slug,
|
|
1953
|
+
kind: 'shared',
|
|
1954
|
+
joinable: true,
|
|
1955
|
+
shared: { slug: w.slug, ownerEmail: w.owner_email, role: w.role },
|
|
1956
|
+
lastOpenedAt: 0,
|
|
1957
|
+
}));
|
|
1958
|
+
return [...local, ...joinable];
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
app.get('/api/lobby/workspaces', async (c) => {
|
|
1914
1962
|
const forbidden = require(c, 'fileTree');
|
|
1915
1963
|
if (forbidden) return forbidden;
|
|
1916
|
-
return c.json({ workspaces:
|
|
1964
|
+
return c.json({ workspaces: await lobbyListMerged(), defaultId: defaultWorkspace.id });
|
|
1917
1965
|
});
|
|
1918
1966
|
|
|
1919
1967
|
app.post('/api/lobby/workspaces', async (c) => {
|
|
@@ -1969,6 +2017,224 @@ export async function createServer(overrides = {}) {
|
|
|
1969
2017
|
return c.json({ ok: true, removed });
|
|
1970
2018
|
});
|
|
1971
2019
|
|
|
2020
|
+
// --- sharing slice (design §4): promote a local workspace to shared + invite ---
|
|
2021
|
+
// "Share" mints a first-class shared workspace on the bmo-sync rails (its own
|
|
2022
|
+
// slug + member set) and flips THIS install's registry entry to kind:'shared'.
|
|
2023
|
+
// The folder never moves (decision 7). HOST-only (you share the folder where it
|
|
2024
|
+
// lives) + owner-level (fileTree) + must be logged in (the rails identity is the
|
|
2025
|
+
// account's). The Decision-11 "this carries the files AND the agent's memory"
|
|
2026
|
+
// heads-up lives on the client; the server just mints + records.
|
|
2027
|
+
|
|
2028
|
+
// Resolve a lobby workspace id to its registry entry, seeding the computed
|
|
2029
|
+
// boot/default workspace on first touch so it can be shared like any other.
|
|
2030
|
+
function lobbyEntryFor(id) {
|
|
2031
|
+
let entry = getWorkspace(id, registryEnv);
|
|
2032
|
+
if (!entry && id === defaultWorkspace.id) {
|
|
2033
|
+
entry = seedDefault(
|
|
2034
|
+
{ dir: defaultWorkspace.dir, name: defaultWorkspace.name },
|
|
2035
|
+
registryEnv,
|
|
2036
|
+
);
|
|
2037
|
+
}
|
|
2038
|
+
return entry;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// Slice 3 "going live": start file-sync for a shared workspace by minting a
|
|
2042
|
+
// membership-gated pass on the rails (provisions the file-sync project on first
|
|
2043
|
+
// call) and pairing the LOCAL daemon to the folder, so it backs up to VW's
|
|
2044
|
+
// rails. Best-effort by design — a down daemon or a rails hiccup must NOT fail
|
|
2045
|
+
// Share (the membership identity is already minted); it returns a status the UI
|
|
2046
|
+
// can surface ("backing up…" / "we'll start syncing when the daemon is up").
|
|
2047
|
+
// Idempotent: a workspace already linked + already syncing is a no-op success.
|
|
2048
|
+
async function startWorkspaceSync(entry, slug) {
|
|
2049
|
+
try {
|
|
2050
|
+
const linked = entry.shared?.projectCode || null;
|
|
2051
|
+
if (linked) {
|
|
2052
|
+
// Already provisioned — if the daemon is already syncing it, nothing to do.
|
|
2053
|
+
const ws = await syncControl.listWorkspaces();
|
|
2054
|
+
if (ws.some((w) => w.workspaceId === linked || w.projectId === linked)) {
|
|
2055
|
+
return { synced: true, projectCode: linked };
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
const cred = await workspaceRails.syncCredential(slug);
|
|
2059
|
+
if (!cred.ok || !cred.data?.invite_code) {
|
|
2060
|
+
return { synced: false, reason: cred.code || 'rails_unreachable', message: cred.message };
|
|
2061
|
+
}
|
|
2062
|
+
const projectCode = cred.data.project_code;
|
|
2063
|
+
try {
|
|
2064
|
+
await syncControl.pair(cred.data.invite_code, entry.dir);
|
|
2065
|
+
} catch (e) {
|
|
2066
|
+
// The daemon is down / unreachable — file-sync will start once it's up and
|
|
2067
|
+
// the owner re-shares (or a future watcher re-pairs). Membership stands.
|
|
2068
|
+
return {
|
|
2069
|
+
synced: false,
|
|
2070
|
+
reason: 'daemon_unreachable',
|
|
2071
|
+
message: String(e.message || e),
|
|
2072
|
+
projectCode,
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
setSyncProject(entry.id, projectCode, registryEnv);
|
|
2076
|
+
return { synced: true, projectCode };
|
|
2077
|
+
} catch (e) {
|
|
2078
|
+
// Belt-and-braces: file-sync is best-effort — ANY failure here leaves the
|
|
2079
|
+
// workspace shared (membership minted) and just reports sync didn't start.
|
|
2080
|
+
return { synced: false, reason: 'sync_error', message: String(e?.message || e) };
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
app.post('/api/lobby/workspaces/:id/share', async (c) => {
|
|
2085
|
+
const forbidden = require(c, 'fileTree');
|
|
2086
|
+
if (forbidden) return forbidden;
|
|
2087
|
+
if (!isHostRequest(c)) {
|
|
2088
|
+
return c.json(
|
|
2089
|
+
{
|
|
2090
|
+
error: 'host_only',
|
|
2091
|
+
message: 'Share a workspace from the computer where its folder lives.',
|
|
2092
|
+
},
|
|
2093
|
+
403,
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
if (!config.account?.email || !workspaceRails.capable) {
|
|
2097
|
+
return c.json(
|
|
2098
|
+
{
|
|
2099
|
+
error: 'login_required',
|
|
2100
|
+
message: 'Sign in to VentureWild first — sharing gives the workspace an identity on the rails.',
|
|
2101
|
+
},
|
|
2102
|
+
400,
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
const id = c.req.param('id');
|
|
2106
|
+
const entry = lobbyEntryFor(id);
|
|
2107
|
+
if (!entry) return c.json({ error: 'no_such_workspace' }, 404);
|
|
2108
|
+
const wasShared = entry.kind === 'shared' && Boolean(entry.shared?.slug);
|
|
2109
|
+
// Mint the membership identity (idempotent: an already-shared workspace keeps
|
|
2110
|
+
// its slug). Then "go live" — start backing the folder up. The file-sync step
|
|
2111
|
+
// runs on BOTH a fresh share AND a re-share of an already-shared-but-not-yet-
|
|
2112
|
+
// synced workspace, so a Share that happened before the daemon was up heals.
|
|
2113
|
+
let shared = entry;
|
|
2114
|
+
if (!wasShared) {
|
|
2115
|
+
const r = await workspaceRails.create(entry.name);
|
|
2116
|
+
if (!r.ok) {
|
|
2117
|
+
// Surface the rails' code/message (slug_taken / unreachable / …) at a sane status.
|
|
2118
|
+
const status = r.status >= 400 && r.status < 600 ? r.status : 502;
|
|
2119
|
+
return c.json({ error: r.code || 'share_failed', message: r.message }, status);
|
|
2120
|
+
}
|
|
2121
|
+
shared = markShared(
|
|
2122
|
+
id,
|
|
2123
|
+
{ slug: r.data.slug, ownerEmail: config.account.email, role: 'owner' },
|
|
2124
|
+
registryEnv,
|
|
2125
|
+
);
|
|
2126
|
+
auditAction(c, 'lobby.share', `id=${id} slug=${r.data.slug}`);
|
|
2127
|
+
}
|
|
2128
|
+
if (!shared?.shared?.slug) return c.json({ error: 'share_failed' }, 500);
|
|
2129
|
+
const sync = await startWorkspaceSync(shared, shared.shared.slug);
|
|
2130
|
+
auditAction(c, 'lobby.share.sync', `id=${id} slug=${shared.shared.slug} synced=${sync.synced}`);
|
|
2131
|
+
const finalEntry = getWorkspace(id, registryEnv) || shared;
|
|
2132
|
+
return c.json({ ok: true, alreadyShared: wasShared, workspace: lobbyShape(finalEntry), sync });
|
|
2133
|
+
});
|
|
2134
|
+
|
|
2135
|
+
app.post('/api/lobby/workspaces/:id/invite', async (c) => {
|
|
2136
|
+
const forbidden = require(c, 'fileTree');
|
|
2137
|
+
if (forbidden) return forbidden;
|
|
2138
|
+
if (!isHostRequest(c)) return c.json({ error: 'host_only' }, 403);
|
|
2139
|
+
if (!config.account?.email || !workspaceRails.capable) {
|
|
2140
|
+
return c.json({ error: 'login_required' }, 400);
|
|
2141
|
+
}
|
|
2142
|
+
const id = c.req.param('id');
|
|
2143
|
+
const entry = getWorkspace(id, registryEnv);
|
|
2144
|
+
if (!entry || entry.kind !== 'shared' || !entry.shared?.slug) {
|
|
2145
|
+
return c.json({ error: 'not_shared', message: 'Share this workspace before inviting people.' }, 400);
|
|
2146
|
+
}
|
|
2147
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2148
|
+
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
|
2149
|
+
if (!email || !email.includes('@')) {
|
|
2150
|
+
return c.json({ error: 'invalid_email', message: 'Enter a teammate’s email address.' }, 400);
|
|
2151
|
+
}
|
|
2152
|
+
const r = await workspaceRails.addMember(entry.shared.slug, email);
|
|
2153
|
+
if (!r.ok) {
|
|
2154
|
+
const status = r.status >= 400 && r.status < 600 ? r.status : 502;
|
|
2155
|
+
return c.json({ error: r.code || 'invite_failed', message: r.message }, status);
|
|
2156
|
+
}
|
|
2157
|
+
// No account yet → the rails recorded a PENDING invite; they'll claim it by
|
|
2158
|
+
// signing in with Google + Join (no account-first requirement). Already-an-
|
|
2159
|
+
// account → active immediately.
|
|
2160
|
+
const pending = Boolean(r.data?.pending);
|
|
2161
|
+
auditAction(c, 'lobby.invite', `slug=${entry.shared.slug} email=${email} pending=${pending}`);
|
|
2162
|
+
return c.json({ ok: true, email, pending, accountId: r.data?.account_id || null });
|
|
2163
|
+
});
|
|
2164
|
+
|
|
2165
|
+
// Join a shared workspace someone invited you to (design §4 decision 9). The
|
|
2166
|
+
// invitee PICKS where it lives on their disk (a new folder under the Workspaces
|
|
2167
|
+
// home, or an existing one — same mechanic as create), then it's registered
|
|
2168
|
+
// locally + marked shared with this member's role. HOST-only (the folder lives
|
|
2169
|
+
// here) + login. Membership is verified against the rails list, so you can only
|
|
2170
|
+
// give a local home to a workspace you actually belong to. This binds the
|
|
2171
|
+
// IDENTITY (so the per-person canvas syncs) AND — Slice 3 "filling in" — starts
|
|
2172
|
+
// file-sync via the SAME `startWorkspaceSync` the owner uses: the joiner's daemon
|
|
2173
|
+
// pairs the chosen folder and pulls the current state, so it fills with the
|
|
2174
|
+
// owner's files + agent-brain instead of landing empty. Best-effort: a down
|
|
2175
|
+
// daemon still joins you (you land in the folder; it fills once sync starts).
|
|
2176
|
+
app.post('/api/lobby/workspaces/join', async (c) => {
|
|
2177
|
+
const forbidden = require(c, 'fileTree');
|
|
2178
|
+
if (forbidden) return forbidden;
|
|
2179
|
+
if (!isHostRequest(c)) {
|
|
2180
|
+
return c.json(
|
|
2181
|
+
{ error: 'host_only', message: 'Join a shared workspace from the computer where you want its folder.' },
|
|
2182
|
+
403,
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
if (!config.account?.email || !workspaceRails.capable) {
|
|
2186
|
+
return c.json({ error: 'login_required' }, 400);
|
|
2187
|
+
}
|
|
2188
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2189
|
+
const slug = typeof body.slug === 'string' ? body.slug.trim().toLowerCase() : '';
|
|
2190
|
+
if (!slug) return c.json({ error: 'slug_required' }, 400);
|
|
2191
|
+
// Claim membership on the rails: this BOTH verifies the caller belongs (an
|
|
2192
|
+
// active member OR a pending invite addressed to their verified email) AND
|
|
2193
|
+
// activates a pending invite (invited→active) — the invite-a-teammate-who-
|
|
2194
|
+
// has-no-account-yet payoff. 403 not_a_member if neither.
|
|
2195
|
+
const claimed = await workspaceRails.claim(slug);
|
|
2196
|
+
if (!claimed.ok) {
|
|
2197
|
+
if (claimed.code === 'not_a_member' || claimed.status === 403) {
|
|
2198
|
+
return c.json({ error: 'not_a_member', message: 'You’re not a member of that workspace.' }, 403);
|
|
2199
|
+
}
|
|
2200
|
+
const status = claimed.status >= 400 && claimed.status < 600 ? claimed.status : 502;
|
|
2201
|
+
return c.json({ error: claimed.code || 'join_failed', message: claimed.message }, status);
|
|
2202
|
+
}
|
|
2203
|
+
const ws = {
|
|
2204
|
+
name: claimed.data?.name || slug,
|
|
2205
|
+
role: claimed.data?.role || 'member',
|
|
2206
|
+
owner_email: claimed.data?.owner_email || '',
|
|
2207
|
+
};
|
|
2208
|
+
// Already have a local home for it? Return that (idempotent re-join), and
|
|
2209
|
+
// (re)start file-sync so a join that happened before the daemon was up heals.
|
|
2210
|
+
const existing = listWorkspaces(registryEnv).find((w) => w.shared?.slug === slug);
|
|
2211
|
+
if (existing) {
|
|
2212
|
+
touchWorkspace(existing.id, registryEnv);
|
|
2213
|
+
const sync = await startWorkspaceSync(existing, slug);
|
|
2214
|
+
const finalEntry = getWorkspace(existing.id, registryEnv) || existing;
|
|
2215
|
+
return c.json({ ok: true, alreadyJoined: true, workspace: lobbyShape(finalEntry), sync });
|
|
2216
|
+
}
|
|
2217
|
+
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : ws.name;
|
|
2218
|
+
const dir = typeof body.dir === 'string' ? body.dir.trim() : '';
|
|
2219
|
+
let entry;
|
|
2220
|
+
try {
|
|
2221
|
+
entry = registryCreateWorkspace({ name, dir: dir || undefined }, registryEnv);
|
|
2222
|
+
} catch (e) {
|
|
2223
|
+
return c.json({ error: 'join_failed', message: String(e.message || e) }, 400);
|
|
2224
|
+
}
|
|
2225
|
+
const updated = markShared(
|
|
2226
|
+
entry.id,
|
|
2227
|
+
{ slug, ownerEmail: ws.owner_email, role: ws.role || 'member' },
|
|
2228
|
+
registryEnv,
|
|
2229
|
+
);
|
|
2230
|
+
auditAction(c, 'lobby.join', `id=${entry.id} slug=${slug} claimed=${Boolean(claimed.data?.claimed)}`);
|
|
2231
|
+
// Filling in: pair the joiner's daemon → it pulls the owner's files + brain.
|
|
2232
|
+
const sync = await startWorkspaceSync(updated, slug);
|
|
2233
|
+
auditAction(c, 'lobby.join.sync', `id=${entry.id} slug=${slug} synced=${sync.synced}`);
|
|
2234
|
+
const finalEntry = getWorkspace(entry.id, registryEnv) || updated;
|
|
2235
|
+
return c.json({ ok: true, workspace: lobbyShape(finalEntry), sync });
|
|
2236
|
+
});
|
|
2237
|
+
|
|
1972
2238
|
app.get('/api/workspace/tree', async (c) => {
|
|
1973
2239
|
if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
|
|
1974
2240
|
return c.json({ error: 'forbidden' }, 403);
|
|
@@ -10,9 +10,13 @@
|
|
|
10
10
|
// Creating/opening a workspace is a HOST action (the folder lives here); a remote
|
|
11
11
|
// browser only ENTERS existing ones — enforced at the route, not here.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// A workspace is `kind:'local'` until the owner explicitly SHARES it (sharing
|
|
14
|
+
// slice / design §4 decision 7): `markShared` promotes the entry to `kind:'shared'`
|
|
15
|
+
// and records a `shared:{slug,ownerEmail,role,sharedAt}` block — the slug is the
|
|
16
|
+
// workspace's first-class identity on the bmo-sync rails (membership/canvas), minted
|
|
17
|
+
// by `POST /api/workspaces/create`. The folder stays exactly where it is; promoting
|
|
18
|
+
// is a metadata flip on the LIST, never a move. `removeWorkspace` drops a folder
|
|
19
|
+
// from the LIST only — it never deletes files.
|
|
16
20
|
|
|
17
21
|
import fs from 'node:fs';
|
|
18
22
|
import path from 'node:path';
|
|
@@ -71,17 +75,40 @@ function readRaw(env) {
|
|
|
71
75
|
return [];
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
// A shared workspace carries a `shared` block (the rails identity + the local
|
|
79
|
+
// member's role). Only kept when the entry is actually `kind:'shared'` AND the
|
|
80
|
+
// block has a slug; anything else collapses back to a plain local entry so a
|
|
81
|
+
// half-written record can't masquerade as shared.
|
|
82
|
+
function sanitizeShared(s) {
|
|
83
|
+
if (!s || typeof s !== 'object' || typeof s.slug !== 'string' || !s.slug.trim()) return null;
|
|
84
|
+
const out = {
|
|
85
|
+
slug: s.slug.trim().toLowerCase(),
|
|
86
|
+
ownerEmail: typeof s.ownerEmail === 'string' ? s.ownerEmail : '',
|
|
87
|
+
role: s.role === 'owner' ? 'owner' : 'member',
|
|
88
|
+
sharedAt: Number(s.sharedAt) || 0,
|
|
89
|
+
};
|
|
90
|
+
// Slice 3: the file-sync project this workspace replicates through, recorded
|
|
91
|
+
// once the daemon is paired ("going live"). Absent until file-sync starts.
|
|
92
|
+
if (typeof s.projectCode === 'string' && s.projectCode.trim()) {
|
|
93
|
+
out.projectCode = s.projectCode.trim();
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
74
98
|
function sanitize(e) {
|
|
75
99
|
if (!e || typeof e !== 'object' || typeof e.dir !== 'string' || !e.dir) return null;
|
|
76
100
|
const dir = path.resolve(e.dir);
|
|
77
|
-
|
|
101
|
+
const shared = sanitizeShared(e.shared);
|
|
102
|
+
const out = {
|
|
78
103
|
id: typeof e.id === 'string' && e.id ? e.id : idForDir(dir),
|
|
79
104
|
name: typeof e.name === 'string' && e.name.trim() ? e.name.trim() : nameForDir(dir),
|
|
80
105
|
dir,
|
|
81
|
-
kind: e.kind === 'shared' ? 'shared' : 'local',
|
|
106
|
+
kind: e.kind === 'shared' && shared ? 'shared' : 'local',
|
|
82
107
|
createdAt: Number(e.createdAt) || nowMs(),
|
|
83
108
|
lastOpenedAt: Number(e.lastOpenedAt) || 0,
|
|
84
109
|
};
|
|
110
|
+
if (out.kind === 'shared') out.shared = shared;
|
|
111
|
+
return out;
|
|
85
112
|
}
|
|
86
113
|
|
|
87
114
|
function writeRaw(list, env) {
|
|
@@ -215,6 +242,49 @@ export function touchWorkspace(id, env = process.env) {
|
|
|
215
242
|
return w;
|
|
216
243
|
}
|
|
217
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Promote a registered workspace to SHARED (sharing slice / design §4 decision 7).
|
|
247
|
+
* The workspace must already be in the registry (the boot/default workspace is
|
|
248
|
+
* seeded first by the caller). Records the rails identity (`slug`) + the local
|
|
249
|
+
* member's role; the folder is untouched. Idempotent: re-sharing updates the block.
|
|
250
|
+
* Returns the updated entry, or null if the id is unknown.
|
|
251
|
+
*/
|
|
252
|
+
export function markShared(id, { slug, ownerEmail = '', role = 'owner' } = {}, env = process.env) {
|
|
253
|
+
if (!slug || typeof slug !== 'string') throw new Error('markShared requires a slug');
|
|
254
|
+
const list = readRaw(env);
|
|
255
|
+
const w = list.find((x) => x.id === id);
|
|
256
|
+
if (!w) return null;
|
|
257
|
+
w.kind = 'shared';
|
|
258
|
+
w.shared = sanitizeShared({
|
|
259
|
+
slug,
|
|
260
|
+
ownerEmail,
|
|
261
|
+
role,
|
|
262
|
+
sharedAt: w.shared?.sharedAt || nowMs(),
|
|
263
|
+
// Preserve a file-sync link already recorded (re-share/re-mark must not drop it).
|
|
264
|
+
projectCode: w.shared?.projectCode,
|
|
265
|
+
});
|
|
266
|
+
writeRaw(list, env);
|
|
267
|
+
return w;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Record the file-sync project a shared workspace replicates through, once the
|
|
272
|
+
* daemon has paired the folder (Slice 3 "going live"). The entry must already be
|
|
273
|
+
* `kind:'shared'`. Idempotent. Returns the updated entry, or null if the id is
|
|
274
|
+
* unknown / not shared.
|
|
275
|
+
*/
|
|
276
|
+
export function setSyncProject(id, projectCode, env = process.env) {
|
|
277
|
+
if (!projectCode || typeof projectCode !== 'string') {
|
|
278
|
+
throw new Error('setSyncProject requires a projectCode');
|
|
279
|
+
}
|
|
280
|
+
const list = readRaw(env);
|
|
281
|
+
const w = list.find((x) => x.id === id);
|
|
282
|
+
if (!w || w.kind !== 'shared' || !w.shared) return null;
|
|
283
|
+
w.shared = sanitizeShared({ ...w.shared, projectCode });
|
|
284
|
+
writeRaw(list, env);
|
|
285
|
+
return w;
|
|
286
|
+
}
|
|
287
|
+
|
|
218
288
|
/** Remove a workspace from the LIST only. NEVER deletes the folder or its files. */
|
|
219
289
|
export function removeWorkspace(id, env = process.env) {
|
|
220
290
|
const list = readRaw(env);
|
|
@@ -81,11 +81,35 @@ export class WorkspaceRails {
|
|
|
81
81
|
return this._post('/api/workspaces/list', {});
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Add a member by email (owner-only). If the email has no account yet the
|
|
86
|
+
* rails records a PENDING invite (`data.pending === true`) instead of an
|
|
87
|
+
* active membership; the person claims it by signing in + Join.
|
|
88
|
+
*/
|
|
85
89
|
addMember(slug, email) {
|
|
86
90
|
return this._post('/api/workspaces/members/add', { slug, member_email: email });
|
|
87
91
|
}
|
|
88
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Claim membership at Join time. Activates a pending invite addressed to this
|
|
95
|
+
* account's own VERIFIED email; a no-op for an already-active member. Returns
|
|
96
|
+
* `{ok, data:{role, name, owner_email, claimed}}` or a 403 `not_a_member`.
|
|
97
|
+
*/
|
|
98
|
+
claim(slug) {
|
|
99
|
+
return this._post('/api/workspaces/claim', { slug });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Mint a file-sync pass for a shared workspace this account belongs to
|
|
104
|
+
* (Slice 3). Returns `{ok, data:{project_code, invite_code, expires_at}}` — the
|
|
105
|
+
* daemon redeems `invite_code` to pair the folder and start replicating. The
|
|
106
|
+
* project is provisioned lazily server-side; the code is stable, the invite is
|
|
107
|
+
* one-shot (each call mints a fresh one). A non-member → 403 `not_a_member`.
|
|
108
|
+
*/
|
|
109
|
+
syncCredential(slug) {
|
|
110
|
+
return this._post('/api/workspaces/sync-credential', { slug });
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
/** Remove a member by email or accountId (owner-only; the owner can't be removed). */
|
|
90
114
|
removeMember(slug, ref) {
|
|
91
115
|
const body = { slug };
|