@venturewild/workspace 0.1.0 → 0.1.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/LICENSE +21 -21
- package/README.md +73 -73
- package/package.json +75 -69
- package/server/bin/wild-workspace.mjs +402 -95
- package/server/src/account.mjs +114 -0
- package/server/src/agent-identity.mjs +65 -0
- package/server/src/agent.mjs +356 -335
- package/server/src/config.mjs +272 -236
- package/server/src/daemon-supervisor.mjs +216 -0
- package/server/src/daemon.mjs +6 -0
- package/server/src/error-reporter.mjs +86 -0
- package/server/src/inbox.mjs +86 -81
- package/server/src/index.mjs +1099 -635
- package/server/src/sync.mjs +248 -176
- package/web/dist/assets/index-B2EifA0K.js +89 -0
- package/web/dist/assets/index-CsFUQhvj.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DOwej8U4.js +0 -89
- package/web/dist/assets/index-DZkyDo10.css +0 -1
|
@@ -1,95 +1,402 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// `wild-workspace` CLI entry — the bin field in package.json.
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
wild-workspace
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `wild-workspace` CLI entry — the bin field in package.json.
|
|
3
|
+
// Starts the workspace server; also exposes a `daemon` subcommand for
|
|
4
|
+
// inspecting / controlling the bmo-sync sync daemon.
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import url from 'node:url';
|
|
8
|
+
import { createServer } from '../src/index.mjs';
|
|
9
|
+
import { APP_VERSION, buildConfig } from '../src/config.mjs';
|
|
10
|
+
import { DaemonSupervisor } from '../src/daemon-supervisor.mjs';
|
|
11
|
+
import { SyncControl } from '../src/sync.mjs';
|
|
12
|
+
import {
|
|
13
|
+
decodeLoginPayload,
|
|
14
|
+
saveAccount,
|
|
15
|
+
loadAccount,
|
|
16
|
+
clearAccount,
|
|
17
|
+
} from '../src/account.mjs';
|
|
18
|
+
|
|
19
|
+
const __filename = url.fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname = path.dirname(__filename);
|
|
21
|
+
|
|
22
|
+
function printUsage() {
|
|
23
|
+
console.log(`wild-workspace v${APP_VERSION}
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
wild-workspace start the workspace server in the current directory
|
|
27
|
+
wild-workspace --port 5173 override port (default 5173)
|
|
28
|
+
wild-workspace --no-open don't auto-open browser
|
|
29
|
+
wild-workspace --host 0.0.0.0 bind to all interfaces (for share-by-URL hosting)
|
|
30
|
+
wild-workspace daemon status is the bmo-sync daemon running?
|
|
31
|
+
wild-workspace daemon start start the sync daemon now
|
|
32
|
+
wild-workspace daemon stop stop the sync daemon
|
|
33
|
+
wild-workspace daemon conflicts list list open conflicts
|
|
34
|
+
wild-workspace daemon conflicts show <wid> <path> view one conflict
|
|
35
|
+
wild-workspace daemon conflicts resolve <wid> <path> <keep_mine|take_theirs>
|
|
36
|
+
wild-workspace login <payload> bind this install to a slug
|
|
37
|
+
(paste the blob from workspace.venturewild.llc)
|
|
38
|
+
wild-workspace logout clear the bound account (slug + token)
|
|
39
|
+
wild-workspace whoami show the currently-bound account
|
|
40
|
+
wild-workspace rotate-token mint a new account token; invalidates the old one
|
|
41
|
+
wild-workspace install (info) how the sync daemon is managed
|
|
42
|
+
wild-workspace --help this message
|
|
43
|
+
wild-workspace --version print version
|
|
44
|
+
|
|
45
|
+
The bmo-sync sync daemon starts automatically in the background when you run
|
|
46
|
+
\`wild-workspace\`, and keeps running after you close the browser.
|
|
47
|
+
|
|
48
|
+
Environment:
|
|
49
|
+
WILD_WORKSPACE_PORT, WILD_WORKSPACE_HOST,
|
|
50
|
+
WILD_WORKSPACE_DIR, WILD_WORKSPACE_DATA_DIR,
|
|
51
|
+
WILD_WORKSPACE_PARTNER_TOKEN, WILD_WORKSPACE_SHARE_SECRET,
|
|
52
|
+
WILD_WORKSPACE_NO_OPEN=1, WILD_WORKSPACE_DAEMON_AUTOSTART=0
|
|
53
|
+
`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseArgs(argv) {
|
|
57
|
+
const opts = {};
|
|
58
|
+
const positional = [];
|
|
59
|
+
for (let i = 0; i < argv.length; i++) {
|
|
60
|
+
const arg = argv[i];
|
|
61
|
+
if (arg === '--help' || arg === '-h') opts.help = true;
|
|
62
|
+
else if (arg === '--version' || arg === '-v') opts.version = true;
|
|
63
|
+
else if (arg === '--no-open') opts.openBrowser = false;
|
|
64
|
+
else if (arg === '--port') { opts.port = Number(argv[++i]); }
|
|
65
|
+
else if (arg === '--host') { opts.host = argv[++i]; }
|
|
66
|
+
else if (arg === '--workspace') { opts.workspaceDir = argv[++i]; }
|
|
67
|
+
else if (arg.startsWith('--')) {
|
|
68
|
+
// ignore unknown flags
|
|
69
|
+
} else {
|
|
70
|
+
positional.push(arg);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
opts.positional = positional;
|
|
74
|
+
return opts;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// `wild-workspace daemon [status|start|stop|conflicts ...]`
|
|
78
|
+
async function runDaemonCommand(action = 'status', rest = []) {
|
|
79
|
+
const config = buildConfig({});
|
|
80
|
+
const sup = new DaemonSupervisor({
|
|
81
|
+
httpBase: config.daemonHttpUrl,
|
|
82
|
+
// b-ii: so `wild-workspace daemon start` also opens the proxy link.
|
|
83
|
+
accountToken: config.accountToken,
|
|
84
|
+
serverUrl: config.bmoSyncServerUrl,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (action === 'conflicts') {
|
|
88
|
+
return runConflictsCommand(config, rest);
|
|
89
|
+
}
|
|
90
|
+
if (action === 'status') {
|
|
91
|
+
const s = await sup.status();
|
|
92
|
+
console.log(`bmo-sync daemon: ${s.running ? 'running' : 'stopped'}`);
|
|
93
|
+
console.log(` api : ${s.httpBase}`);
|
|
94
|
+
if (s.pid) console.log(` pid : ${s.pid}`);
|
|
95
|
+
console.log(` log : ${s.logFile}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (action === 'start') {
|
|
99
|
+
const r = await sup.ensureRunning();
|
|
100
|
+
if (r.alreadyRunning) {
|
|
101
|
+
console.log('bmo-sync daemon: already running');
|
|
102
|
+
} else if (r.started) {
|
|
103
|
+
const healthy = await sup.waitForHealthy();
|
|
104
|
+
console.log(
|
|
105
|
+
healthy
|
|
106
|
+
? `bmo-sync daemon: started (pid ${r.pid})`
|
|
107
|
+
: `bmo-sync daemon: launched (pid ${r.pid}) — not yet answering, check the log`,
|
|
108
|
+
);
|
|
109
|
+
} else if (r.error === 'daemon-binary-not-found') {
|
|
110
|
+
console.log('bmo-sync daemon: cannot start — the daemon binary is not installed.');
|
|
111
|
+
console.log(' build it from the bmo-sync workspace and place it under vendor/,');
|
|
112
|
+
console.log(' or install the @venturewild/workspace-daemon-<platform> package.');
|
|
113
|
+
} else {
|
|
114
|
+
console.log(`bmo-sync daemon: could not start — ${r.error}`);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (action === 'stop') {
|
|
119
|
+
const r = await sup.stop();
|
|
120
|
+
console.log(
|
|
121
|
+
r.stopped
|
|
122
|
+
? `bmo-sync daemon: stopped (pid ${r.pid})`
|
|
123
|
+
: `bmo-sync daemon: not stopped — ${r.reason}`,
|
|
124
|
+
);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
console.log(`unknown daemon action: ${action} (use status | start | stop | conflicts)`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// `wild-workspace daemon conflicts [list|show <wid> <path>|resolve <wid> <path> <action>]`
|
|
131
|
+
async function runConflictsCommand(config, args) {
|
|
132
|
+
const sync = new SyncControl({
|
|
133
|
+
daemonHttpUrl: config.daemonHttpUrl,
|
|
134
|
+
bmoSyncServerUrl: config.bmoSyncServerUrl,
|
|
135
|
+
});
|
|
136
|
+
const sub = (args[0] || 'list').toLowerCase();
|
|
137
|
+
if (sub === 'list' || !sub) {
|
|
138
|
+
const conflicts = await sync.listConflicts();
|
|
139
|
+
if (!conflicts.length) {
|
|
140
|
+
console.log('no open conflicts');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
console.log(`open conflicts: ${conflicts.length}`);
|
|
144
|
+
for (const c of conflicts) {
|
|
145
|
+
const detected = new Date((c.detectedAt || 0) * 1000).toISOString();
|
|
146
|
+
console.log(` [${c.workspaceId}] ${c.path}`);
|
|
147
|
+
console.log(` resolution : ${c.resolution}`);
|
|
148
|
+
console.log(` detected_at : ${detected}`);
|
|
149
|
+
if (c.peerBackOfficePath) {
|
|
150
|
+
console.log(` peer_bytes_at : ${c.peerBackOfficePath}`);
|
|
151
|
+
}
|
|
152
|
+
if (c.mineSha256) console.log(` mine_sha256 : ${c.mineSha256}`);
|
|
153
|
+
if (c.theirsSha256) console.log(` theirs_sha256 : ${c.theirsSha256}`);
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (sub === 'show') {
|
|
158
|
+
const wid = args[1];
|
|
159
|
+
const path = args[2];
|
|
160
|
+
if (!wid || !path) {
|
|
161
|
+
console.log('usage: wild-workspace daemon conflicts show <workspace_id> <path>');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const view = await sync.viewConflict(wid, path);
|
|
165
|
+
if (!view) {
|
|
166
|
+
console.log(`no open conflict for ${wid}:${path}`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
console.log(JSON.stringify(view, null, 2));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (sub === 'resolve') {
|
|
173
|
+
const wid = args[1];
|
|
174
|
+
const path = args[2];
|
|
175
|
+
const action = args[3];
|
|
176
|
+
if (!wid || !path || !action) {
|
|
177
|
+
console.log(
|
|
178
|
+
'usage: wild-workspace daemon conflicts resolve <workspace_id> <path> <keep_mine|take_theirs>',
|
|
179
|
+
);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
await sync.resolveConflict(wid, path, action);
|
|
184
|
+
console.log(`resolved: ${wid}:${path} (${action})`);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
console.error(`resolve failed: ${e.message || e}`);
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
console.log(
|
|
192
|
+
`unknown conflicts subcommand: ${sub} (use list | show <wid> <path> | resolve <wid> <path> <action>)`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// `wild-workspace login <base64url-payload>` — bind this install to a slug.
|
|
197
|
+
// The payload comes from workspace.venturewild.llc on signup. It's an opaque
|
|
198
|
+
// blob the user copies once; we decode + persist + print a friendly summary.
|
|
199
|
+
async function runLoginCommand(args) {
|
|
200
|
+
if (!args.length) {
|
|
201
|
+
console.error('usage: wild-workspace login <payload>');
|
|
202
|
+
console.error('');
|
|
203
|
+
console.error('The payload is the blob you copied from workspace.venturewild.llc');
|
|
204
|
+
console.error('after claiming your slug. Run that signup, then come back here.');
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Trim the obvious "wild-workspace login " prefix in case the user pasted
|
|
209
|
+
// the whole command line, and surrounding quotes if a shell preserved them.
|
|
210
|
+
let payload = args.join(' ').trim();
|
|
211
|
+
payload = payload.replace(/^wild-workspace\s+login\s+/i, '').trim();
|
|
212
|
+
payload = payload.replace(/^['"]|['"]$/g, '');
|
|
213
|
+
let parsed;
|
|
214
|
+
try {
|
|
215
|
+
parsed = decodeLoginPayload(payload);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
console.error(`Couldn't decode the login payload: ${e.message || e}`);
|
|
218
|
+
process.exitCode = 1;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const config = buildConfig({});
|
|
222
|
+
let saved;
|
|
223
|
+
try {
|
|
224
|
+
saved = saveAccount(config.dataDir, parsed);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.error(`Couldn't save account to ${config.dataDir}: ${e.message || e}`);
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
console.log(`✓ logged in as ${saved.email}`);
|
|
231
|
+
console.log(` slug : ${saved.slug}`);
|
|
232
|
+
console.log(` url : https://${saved.slug}.venturewild.llc (once the tunnel is configured)`);
|
|
233
|
+
console.log(` saved to : ${config.dataDir}/account.json`);
|
|
234
|
+
console.log('');
|
|
235
|
+
console.log('Run `wild-workspace` in any folder to start your workspace.');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function runLogoutCommand() {
|
|
239
|
+
const config = buildConfig({});
|
|
240
|
+
const before = loadAccount(config.dataDir);
|
|
241
|
+
const removed = clearAccount(config.dataDir);
|
|
242
|
+
if (!removed && !before) {
|
|
243
|
+
console.log('not logged in.');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
console.log(`logged out — cleared ${before?.email || 'account'} from ${config.dataDir}.`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function runRotateTokenCommand() {
|
|
250
|
+
const config = buildConfig({});
|
|
251
|
+
const account = loadAccount(config.dataDir);
|
|
252
|
+
if (!account) {
|
|
253
|
+
console.log('not logged in. Nothing to rotate.');
|
|
254
|
+
console.log('Run `wild-workspace login <payload>` after claiming a slug.');
|
|
255
|
+
process.exitCode = 1;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/account/rotate-token`;
|
|
259
|
+
let resp;
|
|
260
|
+
try {
|
|
261
|
+
resp = await fetch(url, {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
headers: { authorization: `Bearer ${account.accountToken}` },
|
|
264
|
+
});
|
|
265
|
+
} catch (e) {
|
|
266
|
+
console.error(`Couldn't reach ${config.bmoSyncServerUrl}: ${e.message || e}`);
|
|
267
|
+
process.exitCode = 2;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!resp.ok) {
|
|
271
|
+
let body;
|
|
272
|
+
try { body = await resp.text(); } catch { body = ''; }
|
|
273
|
+
console.error(`Rotate failed (HTTP ${resp.status}): ${body.slice(0, 200)}`);
|
|
274
|
+
process.exitCode = 1;
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
let payload;
|
|
278
|
+
try {
|
|
279
|
+
payload = await resp.json();
|
|
280
|
+
} catch (e) {
|
|
281
|
+
console.error(`Server returned non-JSON: ${e.message || e}`);
|
|
282
|
+
process.exitCode = 1;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (!payload || !payload.account_token) {
|
|
286
|
+
console.error('Server response missing account_token. Aborting.');
|
|
287
|
+
process.exitCode = 1;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const updated = {
|
|
291
|
+
...account,
|
|
292
|
+
accountToken: payload.account_token,
|
|
293
|
+
loggedInAt: Date.now(),
|
|
294
|
+
};
|
|
295
|
+
try {
|
|
296
|
+
saveAccount(config.dataDir, updated);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
console.error(`Got new token but failed to persist it: ${e.message || e}`);
|
|
299
|
+
console.error(`New token (save manually): ${payload.account_token}`);
|
|
300
|
+
process.exitCode = 1;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
console.log('✓ token rotated.');
|
|
304
|
+
console.log(` slug : ${updated.slug}`);
|
|
305
|
+
console.log(` saved to : ${config.dataDir}/account.json`);
|
|
306
|
+
console.log('');
|
|
307
|
+
console.log('Your daemon will reconnect with the new token automatically');
|
|
308
|
+
console.log('on its next restart. Run `wild-workspace daemon stop && wild-workspace`');
|
|
309
|
+
console.log('to force an immediate reconnect.');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function runWhoamiCommand() {
|
|
313
|
+
const config = buildConfig({});
|
|
314
|
+
const account = loadAccount(config.dataDir);
|
|
315
|
+
if (!account) {
|
|
316
|
+
console.log('not logged in.');
|
|
317
|
+
console.log('Run `wild-workspace login <payload>` after claiming a slug at workspace.venturewild.llc.');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
console.log(`logged in as ${account.email}`);
|
|
321
|
+
console.log(` slug : ${account.slug}`);
|
|
322
|
+
console.log(` accountId : ${account.accountId}`);
|
|
323
|
+
if (account.displayName) console.log(` name : ${account.displayName}`);
|
|
324
|
+
console.log(` loggedIn : ${new Date(account.loggedInAt).toISOString()}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function main() {
|
|
328
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
329
|
+
if (opts.help) return printUsage();
|
|
330
|
+
if (opts.version) { console.log(APP_VERSION); return; }
|
|
331
|
+
|
|
332
|
+
if (opts.positional[0] === 'daemon') {
|
|
333
|
+
return runDaemonCommand(opts.positional[1], opts.positional.slice(2));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (opts.positional[0] === 'login') {
|
|
337
|
+
return runLoginCommand(opts.positional.slice(1));
|
|
338
|
+
}
|
|
339
|
+
if (opts.positional[0] === 'logout') {
|
|
340
|
+
return runLogoutCommand();
|
|
341
|
+
}
|
|
342
|
+
if (opts.positional[0] === 'whoami') {
|
|
343
|
+
return runWhoamiCommand();
|
|
344
|
+
}
|
|
345
|
+
if (opts.positional[0] === 'rotate-token') {
|
|
346
|
+
return runRotateTokenCommand();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (opts.positional[0] === 'install') {
|
|
350
|
+
console.log('wild-workspace manages the bmo-sync sync daemon for you.');
|
|
351
|
+
console.log('');
|
|
352
|
+
console.log('It starts automatically in the background whenever you run');
|
|
353
|
+
console.log('`wild-workspace`, and keeps running after you close the browser —');
|
|
354
|
+
console.log('so there is no separate install step.');
|
|
355
|
+
console.log('');
|
|
356
|
+
console.log(' wild-workspace daemon status check whether it is running');
|
|
357
|
+
console.log(' wild-workspace daemon stop stop it');
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (opts.positional[0] === 'share') {
|
|
361
|
+
console.log('Use the in-app Share button to issue viewer URLs. CLI share command is v1.x.');
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const server = await createServer(opts);
|
|
366
|
+
const { config } = server;
|
|
367
|
+
console.log(`\n wild-workspace v${APP_VERSION}`);
|
|
368
|
+
console.log(` workspace : ${config.workspaceDir}`);
|
|
369
|
+
console.log(` url : http://${config.host}:${config.port}`);
|
|
370
|
+
console.log(` agent : ${server.getActiveAgent()?.label || '(none detected — install Claude Code: npm i -g @anthropic-ai/claude-code)'}`);
|
|
371
|
+
|
|
372
|
+
// Report how the sync daemon's autostart went (best-effort — never fatal).
|
|
373
|
+
try {
|
|
374
|
+
const d = await server.daemonReady;
|
|
375
|
+
if (d.alreadyRunning) console.log(' sync : bmo-sync daemon already running');
|
|
376
|
+
else if (d.started) console.log(` sync : bmo-sync daemon started (pid ${d.pid})`);
|
|
377
|
+
else if (d.skipped) console.log(' sync : daemon autostart disabled');
|
|
378
|
+
else if (d.error === 'daemon-binary-not-found')
|
|
379
|
+
console.log(' sync : daemon binary not installed — sync is off (see `wild-workspace daemon`)');
|
|
380
|
+
else if (d.error) console.log(` sync : daemon did not start — ${d.error}`);
|
|
381
|
+
} catch {}
|
|
382
|
+
console.log('');
|
|
383
|
+
|
|
384
|
+
if (config.openBrowser) {
|
|
385
|
+
try {
|
|
386
|
+
const open = (await import('open')).default;
|
|
387
|
+
open(`http://${config.host}:${config.port}`);
|
|
388
|
+
} catch {}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
process.on('SIGINT', async () => {
|
|
392
|
+
console.log('\nshutting down…');
|
|
393
|
+
await server.stop();
|
|
394
|
+
process.exit(0);
|
|
395
|
+
});
|
|
396
|
+
process.on('SIGTERM', async () => { await server.stop(); process.exit(0); });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
main().catch((err) => {
|
|
400
|
+
console.error('wild-workspace failed:', err);
|
|
401
|
+
process.exit(1);
|
|
402
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Per-install bmo-sync account — the user's identity in the wild-* ecosystem.
|
|
2
|
+
//
|
|
3
|
+
// Distinct from `agent-identity.mjs` (which holds the agent's name/tone/color)
|
|
4
|
+
// and from `secrets.mjs` (per-install partner/share tokens). An account binds
|
|
5
|
+
// this installation to:
|
|
6
|
+
// - a `slug` (the `<slug>.venturewild.llc` namespace, claimed at signup)
|
|
7
|
+
// - an `email` (human identification — NOT used as a password)
|
|
8
|
+
// - an `accountId` (uuid)
|
|
9
|
+
// - an `accountToken` (long-random secret that proves ownership to
|
|
10
|
+
// `sync.venturewild.llc`; never sent to the browser)
|
|
11
|
+
//
|
|
12
|
+
// Persisted at `<dataDir>/account.json` (mode 0600). Absence means the user
|
|
13
|
+
// has not run `wild-workspace login` yet; the server still works in localhost
|
|
14
|
+
// mode but `<slug>.venturewild.llc` isn't configured.
|
|
15
|
+
//
|
|
16
|
+
// Login flow:
|
|
17
|
+
// 1. User registers at `workspace.venturewild.llc` (landing).
|
|
18
|
+
// 2. Landing displays a single opaque payload (base64url-encoded JSON).
|
|
19
|
+
// 3. User runs `wild-workspace login <payload>`.
|
|
20
|
+
// 4. We decode + persist here.
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
const FILE = 'account.json';
|
|
26
|
+
|
|
27
|
+
function accountPath(dataDir) {
|
|
28
|
+
return path.join(dataDir, FILE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadAccount(dataDir) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(fs.readFileSync(accountPath(dataDir), 'utf8'));
|
|
34
|
+
return sanitize(parsed);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sanitize(raw) {
|
|
41
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
42
|
+
const slug = typeof raw.slug === 'string' ? raw.slug.trim().toLowerCase() : '';
|
|
43
|
+
const email = typeof raw.email === 'string' ? raw.email.trim().toLowerCase() : '';
|
|
44
|
+
const accountId = typeof raw.accountId === 'string' ? raw.accountId.trim() : '';
|
|
45
|
+
const accountToken = typeof raw.accountToken === 'string' ? raw.accountToken.trim() : '';
|
|
46
|
+
if (!slug || !email || !accountId || !accountToken) return null;
|
|
47
|
+
return {
|
|
48
|
+
slug,
|
|
49
|
+
email,
|
|
50
|
+
accountId,
|
|
51
|
+
accountToken,
|
|
52
|
+
displayName: typeof raw.displayName === 'string' ? raw.displayName : null,
|
|
53
|
+
loggedInAt: Number(raw.loggedInAt) || Date.now(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function saveAccount(dataDir, account) {
|
|
58
|
+
const merged = sanitize({ loggedInAt: Date.now(), ...account });
|
|
59
|
+
if (!merged) throw new Error('account-invalid');
|
|
60
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
61
|
+
fs.writeFileSync(accountPath(dataDir), JSON.stringify(merged, null, 2), {
|
|
62
|
+
mode: 0o600,
|
|
63
|
+
});
|
|
64
|
+
return merged;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function clearAccount(dataDir) {
|
|
68
|
+
try {
|
|
69
|
+
fs.unlinkSync(accountPath(dataDir));
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Parse a single opaque token-blob the user pastes from the landing page.
|
|
77
|
+
// The landing emits base64url(JSON({slug,email,accountId,token,displayName?})).
|
|
78
|
+
// We accept either base64url or raw JSON for forward-compat / curl debugging.
|
|
79
|
+
export function decodeLoginPayload(input) {
|
|
80
|
+
const raw = String(input || '').trim();
|
|
81
|
+
if (!raw) throw new Error('Empty login payload — paste the blob from your signup page.');
|
|
82
|
+
// 1. Try base64url decode.
|
|
83
|
+
const tryBase64 = () => {
|
|
84
|
+
let b = raw.replace(/-/g, '+').replace(/_/g, '/');
|
|
85
|
+
while (b.length % 4 !== 0) b += '=';
|
|
86
|
+
return Buffer.from(b, 'base64').toString('utf8');
|
|
87
|
+
};
|
|
88
|
+
// 2. Try raw JSON (if it starts with `{`).
|
|
89
|
+
let jsonText;
|
|
90
|
+
if (raw[0] === '{') {
|
|
91
|
+
jsonText = raw;
|
|
92
|
+
} else {
|
|
93
|
+
try {
|
|
94
|
+
jsonText = tryBase64();
|
|
95
|
+
} catch {
|
|
96
|
+
throw new Error('Login payload is not base64url-encoded JSON.');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(jsonText);
|
|
102
|
+
} catch {
|
|
103
|
+
throw new Error('Login payload decoded but is not valid JSON.');
|
|
104
|
+
}
|
|
105
|
+
// Accept both `token` (what the landing emits) and `accountToken`.
|
|
106
|
+
if (parsed.token && !parsed.accountToken) parsed.accountToken = parsed.token;
|
|
107
|
+
if (!parsed.slug || !parsed.email || !parsed.accountId || !parsed.accountToken) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
'Login payload is missing one or more required fields ' +
|
|
110
|
+
'(slug, email, accountId, token). Re-copy from the signup page?',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return parsed;
|
|
114
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Agent identity — the user-given name, voice/tone, and accent color the agent
|
|
2
|
+
// adopts during onboarding (see docs/onboarding-flow-plan in memory). Persisted
|
|
3
|
+
// to <dataDir>/agent-identity.json so it survives restarts. Absence of this
|
|
4
|
+
// file is how the server detects "first run" and triggers the onboarding UI.
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
|
|
9
|
+
const FILE = 'agent-identity.json';
|
|
10
|
+
|
|
11
|
+
export const TONES = Object.freeze(['concise', 'playful', 'formal', 'dry']);
|
|
12
|
+
|
|
13
|
+
function identityPath(dataDir) {
|
|
14
|
+
return path.join(dataDir, FILE);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loadIdentity(dataDir) {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(fs.readFileSync(identityPath(dataDir), 'utf8'));
|
|
20
|
+
return sanitize(parsed);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sanitize(raw) {
|
|
27
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
28
|
+
const name = typeof raw.name === 'string' ? raw.name.trim().slice(0, 40) : '';
|
|
29
|
+
if (!name) return null;
|
|
30
|
+
const tone = TONES.includes(raw.tone) ? raw.tone : 'concise';
|
|
31
|
+
const color =
|
|
32
|
+
typeof raw.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(raw.color)
|
|
33
|
+
? raw.color
|
|
34
|
+
: '#22d3ee';
|
|
35
|
+
return {
|
|
36
|
+
name,
|
|
37
|
+
tone,
|
|
38
|
+
color,
|
|
39
|
+
createdAt: Number(raw.createdAt) || Date.now(),
|
|
40
|
+
onboardedAt: raw.onboardedAt ? Number(raw.onboardedAt) : null,
|
|
41
|
+
workspaceFirstSeen: raw.workspaceFirstSeen || null,
|
|
42
|
+
connectedServices: Array.isArray(raw.connectedServices)
|
|
43
|
+
? raw.connectedServices.filter((s) => typeof s === 'string')
|
|
44
|
+
: [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function saveIdentity(dataDir, partial) {
|
|
49
|
+
const existing = loadIdentity(dataDir) || {};
|
|
50
|
+
const merged = sanitize({
|
|
51
|
+
createdAt: Date.now(),
|
|
52
|
+
...existing,
|
|
53
|
+
...partial,
|
|
54
|
+
});
|
|
55
|
+
if (!merged) throw new Error('identity-requires-name');
|
|
56
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
57
|
+
fs.writeFileSync(identityPath(dataDir), JSON.stringify(merged, null, 2));
|
|
58
|
+
return merged;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function markOnboarded(dataDir) {
|
|
62
|
+
const existing = loadIdentity(dataDir);
|
|
63
|
+
if (!existing) throw new Error('identity-not-set');
|
|
64
|
+
return saveIdentity(dataDir, { onboardedAt: Date.now() });
|
|
65
|
+
}
|