@lightupai/polaris 0.0.2 → 0.0.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/.github/workflows/ci.yml +4 -1
- package/bin/polaris +9 -1
- package/package.json +1 -1
- package/src/cli/cli.ts +41 -3
- package/src/service/db.ts +14 -2
- package/src/service/server.ts +17 -1
- package/src/slack/system.ts +79 -22
- package/src/web/app.ts +114 -15
- package/src/web/views.ts +80 -34
- package/tests/client.test.ts +2 -2
- package/tests/daemon.test.ts +2 -2
- package/tests/db.test.ts +1 -1
- package/tests/e2e.test.ts +2 -2
- package/tests/service.test.ts +2 -2
- package/tests/web.test.ts +10 -11
package/.github/workflows/ci.yml
CHANGED
|
@@ -24,7 +24,7 @@ jobs:
|
|
|
24
24
|
--health-retries 5
|
|
25
25
|
|
|
26
26
|
env:
|
|
27
|
-
DATABASE_URL: postgres://polaris:polaris@localhost:5432/
|
|
27
|
+
DATABASE_URL: postgres://polaris:polaris@localhost:5432/polaris_test
|
|
28
28
|
|
|
29
29
|
steps:
|
|
30
30
|
- uses: actions/checkout@v4
|
|
@@ -35,4 +35,7 @@ jobs:
|
|
|
35
35
|
|
|
36
36
|
- run: bun install
|
|
37
37
|
|
|
38
|
+
- name: Create test database
|
|
39
|
+
run: PGPASSWORD=polaris psql -h localhost -U polaris -d polaris -c "CREATE DATABASE polaris_test OWNER polaris;"
|
|
40
|
+
|
|
38
41
|
- run: bun test
|
package/bin/polaris
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Polaris CLI wrapper
|
|
3
3
|
DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
# Find bun: direct, via npx, or via bunx
|
|
6
|
+
if command -v bun &>/dev/null; then
|
|
7
|
+
exec bun "$DIR/src/cli/cli.ts" "$@"
|
|
8
|
+
elif command -v bunx &>/dev/null; then
|
|
9
|
+
exec bunx bun "$DIR/src/cli/cli.ts" "$@"
|
|
10
|
+
else
|
|
11
|
+
exec npx --yes bun "$DIR/src/cli/cli.ts" "$@"
|
|
12
|
+
fi
|
package/package.json
CHANGED
package/src/cli/cli.ts
CHANGED
|
@@ -63,7 +63,8 @@ async function login() {
|
|
|
63
63
|
// 2. Wait for the token
|
|
64
64
|
console.log("Waiting for authentication...");
|
|
65
65
|
const token = await tokenPromise;
|
|
66
|
-
|
|
66
|
+
// Keep server alive briefly so the browser can render the success page
|
|
67
|
+
setTimeout(() => callbackServer.stop(true), 3000);
|
|
67
68
|
|
|
68
69
|
// 3. Validate the token and get user info
|
|
69
70
|
const res = await fetch(`${SERVICE_URL}/auth/token?token=${token}`);
|
|
@@ -79,8 +80,20 @@ async function login() {
|
|
|
79
80
|
participant_id: string;
|
|
80
81
|
};
|
|
81
82
|
|
|
83
|
+
// Fetch org name
|
|
84
|
+
let orgName = userInfo.org_id;
|
|
85
|
+
try {
|
|
86
|
+
const orgRes = await fetch(`${SERVICE_URL}/auth/token?token=${token}`);
|
|
87
|
+
if (orgRes.ok) {
|
|
88
|
+
const orgData = (await orgRes.json()) as { org_id: string };
|
|
89
|
+
// The org name isn't in the token — use the email domain as display
|
|
90
|
+
orgName = userInfo.email.split("@")[1].split(".")[0];
|
|
91
|
+
orgName = orgName.charAt(0).toUpperCase() + orgName.slice(1);
|
|
92
|
+
}
|
|
93
|
+
} catch { /* use org_id as fallback */ }
|
|
94
|
+
|
|
82
95
|
console.log(`\nAuthenticated as ${userInfo.name} (${userInfo.email})`);
|
|
83
|
-
console.log(`Organization: ${
|
|
96
|
+
console.log(`Organization: ${orgName}`);
|
|
84
97
|
console.log(`Participant ID: ${userInfo.participant_id}\n`);
|
|
85
98
|
|
|
86
99
|
// 4. Store credentials
|
|
@@ -202,9 +215,34 @@ Based on the arguments provided, do ONE of the following:
|
|
|
202
215
|
await writeFile(settingsPath, JSON.stringify(mergedSettingsWithStatusLine, null, 2));
|
|
203
216
|
console.log(`Status line config written to ${settingsPath}`);
|
|
204
217
|
|
|
218
|
+
// 9. Post system event (device connected)
|
|
219
|
+
const hostname = (await import("node:os")).hostname();
|
|
220
|
+
try {
|
|
221
|
+
await fetch(`${SERVICE_URL.replace(":3000", ":4321")}/projects/_system/sessions/_system/events`, {
|
|
222
|
+
method: "POST",
|
|
223
|
+
headers: {
|
|
224
|
+
"Content-Type": "application/json",
|
|
225
|
+
Authorization: `Bearer ${token}`,
|
|
226
|
+
},
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
sender: userInfo.participant_id,
|
|
229
|
+
payload: {
|
|
230
|
+
hook_event_name: "Stop",
|
|
231
|
+
session_id: "_system",
|
|
232
|
+
stop_response: `Device connected: ${hostname} (${process.platform})`,
|
|
233
|
+
},
|
|
234
|
+
}),
|
|
235
|
+
});
|
|
236
|
+
// Notify web app dashboard to refresh
|
|
237
|
+
await fetch(`${SERVICE_URL}/api/notify-dashboard`, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
240
|
+
});
|
|
241
|
+
} catch { /* non-fatal */ }
|
|
242
|
+
|
|
205
243
|
console.log("\n✓ Polaris is set up on this machine!");
|
|
206
244
|
console.log("\nNext steps:");
|
|
207
|
-
console.log(" 1. Start the daemon:
|
|
245
|
+
console.log(" 1. Start the daemon: polaris daemon");
|
|
208
246
|
console.log(" 2. Open your AI agent and run: /polaris join <project> <session>");
|
|
209
247
|
}
|
|
210
248
|
|
package/src/service/db.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type Sql = postgres.Sql;
|
|
|
8
8
|
export interface Org {
|
|
9
9
|
id: string;
|
|
10
10
|
name: string;
|
|
11
|
+
slug: string | null;
|
|
11
12
|
domain: string | null;
|
|
12
13
|
slack_team_id: string | null;
|
|
13
14
|
slack_bot_token: string | null;
|
|
@@ -33,6 +34,7 @@ export async function createDb(connectionString?: string): Promise<Sql> {
|
|
|
33
34
|
CREATE TABLE IF NOT EXISTS orgs (
|
|
34
35
|
id TEXT PRIMARY KEY,
|
|
35
36
|
name TEXT NOT NULL,
|
|
37
|
+
slug TEXT UNIQUE,
|
|
36
38
|
domain TEXT,
|
|
37
39
|
slack_team_id TEXT,
|
|
38
40
|
slack_bot_token TEXT,
|
|
@@ -119,8 +121,18 @@ export async function getOrgByDomain(sql: Sql, domain: string): Promise<Org | nu
|
|
|
119
121
|
return { ...row, created_at: row.created_at.toISOString() } as Org;
|
|
120
122
|
}
|
|
121
123
|
|
|
122
|
-
export async function setOrgSlack(sql: Sql, orgId: string, teamId: string, botToken: string, systemChannelId?: string): Promise<void> {
|
|
123
|
-
|
|
124
|
+
export async function setOrgSlack(sql: Sql, orgId: string, teamId: string, botToken: string, systemChannelId?: string, slug?: string): Promise<void> {
|
|
125
|
+
if (slug) {
|
|
126
|
+
await sql`UPDATE orgs SET slack_team_id = ${teamId}, slack_bot_token = ${botToken}, slack_system_channel_id = ${systemChannelId ?? null}, slug = ${slug} WHERE id = ${orgId}`;
|
|
127
|
+
} else {
|
|
128
|
+
await sql`UPDATE orgs SET slack_team_id = ${teamId}, slack_bot_token = ${botToken}, slack_system_channel_id = ${systemChannelId ?? null} WHERE id = ${orgId}`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function getOrgBySlug(sql: Sql, slug: string): Promise<Org | null> {
|
|
133
|
+
const [row] = await sql`SELECT * FROM orgs WHERE slug = ${slug}`;
|
|
134
|
+
if (!row) return null;
|
|
135
|
+
return { ...row, created_at: row.created_at.toISOString() } as Org;
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
// --- Users ---
|
package/src/service/server.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type Server, type ServerWebSocket } from "bun";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { postToSlackSystemChannel } from "../slack/system";
|
|
3
4
|
import {
|
|
4
5
|
createDb,
|
|
5
6
|
createOrg,
|
|
7
|
+
getOrg,
|
|
6
8
|
createProject,
|
|
7
9
|
getProject,
|
|
8
10
|
createSession,
|
|
@@ -285,7 +287,12 @@ export async function startServer(opts: {
|
|
|
285
287
|
|
|
286
288
|
params = matchRoute(method, pathname, "/projects/:proj/sessions/:sess/events", "POST");
|
|
287
289
|
if (params) {
|
|
288
|
-
|
|
290
|
+
let session = await getSession(sql, orgId, params.proj, params.sess);
|
|
291
|
+
if (!session && params.proj === "_system") {
|
|
292
|
+
// Auto-create _system project and session
|
|
293
|
+
try { await createProject(sql, orgId, "_system"); } catch { /* exists */ }
|
|
294
|
+
try { session = await createSession(sql, orgId, "_system", "_system", null); } catch { session = await getSession(sql, orgId, "_system", "_system"); }
|
|
295
|
+
}
|
|
289
296
|
if (!session) return error("Session not found", 404);
|
|
290
297
|
const body = await jsonBody(req);
|
|
291
298
|
const parsed = z
|
|
@@ -304,6 +311,15 @@ export async function startServer(opts: {
|
|
|
304
311
|
await pushEvent(sql, orgId, event);
|
|
305
312
|
broadcastEvent(event);
|
|
306
313
|
broadcastSse(event);
|
|
314
|
+
|
|
315
|
+
// Forward _system events to Slack
|
|
316
|
+
if (params.proj === "_system") {
|
|
317
|
+
const text = (parsed.data.payload as { stop_response?: string; prompt?: string }).stop_response
|
|
318
|
+
?? (parsed.data.payload as { prompt?: string }).prompt
|
|
319
|
+
?? "System event";
|
|
320
|
+
await postToSlackSystemChannel(sql, orgId, `:computer: *${parsed.data.sender}*: ${text}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
307
323
|
return json(event, 201);
|
|
308
324
|
}
|
|
309
325
|
|
package/src/slack/system.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
// --- Slack system channel: create, post events ---
|
|
1
|
+
// --- Slack system channel: create, post events, persist to DB ---
|
|
2
|
+
|
|
3
|
+
import { pushEvent, createProject, getProject, getOrg, type Sql } from "../service/db";
|
|
4
|
+
import type { PolarisEvent } from "../types";
|
|
2
5
|
|
|
3
6
|
const SYSTEM_CHANNEL_NAME = "polaris-system";
|
|
4
7
|
|
|
@@ -78,30 +81,84 @@ export async function createSystemChannel(botToken: string, inviteEmail?: string
|
|
|
78
81
|
throw new Error(`Failed to create or find #${SYSTEM_CHANNEL_NAME} channel: ${createRes.error}`);
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
84
|
+
// Ensure the _system project exists for an org.
|
|
85
|
+
async function ensureSystemProject(sql: Sql, orgId: string): Promise<void> {
|
|
86
|
+
const existing = await getProject(sql, orgId, "_system");
|
|
87
|
+
if (!existing) {
|
|
88
|
+
try {
|
|
89
|
+
await createProject(sql, orgId, "_system");
|
|
90
|
+
} catch {
|
|
91
|
+
// Already exists (race condition)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Post a system event — persists to DB and optionally posts to Slack.
|
|
97
|
+
export async function postSystemEvent(opts: {
|
|
98
|
+
sql: Sql;
|
|
99
|
+
orgId: string;
|
|
100
|
+
sender: string;
|
|
101
|
+
text: string;
|
|
102
|
+
context?: string;
|
|
103
|
+
botToken?: string;
|
|
104
|
+
channelId?: string;
|
|
105
|
+
}): Promise<void> {
|
|
106
|
+
const { sql, orgId, sender, text, context, botToken, channelId } = opts;
|
|
107
|
+
|
|
108
|
+
// Persist to DB under _system project and _system session
|
|
109
|
+
await ensureSystemProject(sql, orgId);
|
|
110
|
+
const event: PolarisEvent = {
|
|
111
|
+
id: crypto.randomUUID(),
|
|
112
|
+
project: "_system",
|
|
113
|
+
session: "_system",
|
|
114
|
+
timestamp: new Date().toISOString(),
|
|
115
|
+
source: "inject",
|
|
116
|
+
sender: sender as PolarisEvent["sender"],
|
|
117
|
+
payload: {
|
|
118
|
+
type: "inject" as const,
|
|
119
|
+
content: context ? `${text}\n${context}` : text,
|
|
120
|
+
sender: sender as PolarisEvent["sender"],
|
|
121
|
+
target: "_system",
|
|
92
122
|
},
|
|
93
|
-
|
|
123
|
+
};
|
|
124
|
+
await pushEvent(sql, orgId, event);
|
|
94
125
|
|
|
95
|
-
if
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
126
|
+
// Post to Slack if connected
|
|
127
|
+
if (botToken && channelId) {
|
|
128
|
+
const blocks: Array<Record<string, unknown>> = [
|
|
129
|
+
{
|
|
130
|
+
type: "section",
|
|
131
|
+
text: { type: "mrkdwn", text },
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
if (context) {
|
|
136
|
+
blocks.push({
|
|
137
|
+
type: "context",
|
|
138
|
+
elements: [{ type: "mrkdwn", text: context }],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await slackApi(botToken, "chat.postMessage", {
|
|
143
|
+
channel: channelId,
|
|
144
|
+
text,
|
|
145
|
+
blocks,
|
|
99
146
|
});
|
|
100
147
|
}
|
|
148
|
+
}
|
|
101
149
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
150
|
+
// Post a _system event to Slack if the org has credentials.
|
|
151
|
+
// Called by the cloud service when it receives a _system project event.
|
|
152
|
+
export async function postToSlackSystemChannel(sql: Sql, orgId: string, text: string): Promise<void> {
|
|
153
|
+
try {
|
|
154
|
+
const org = await getOrg(sql, orgId);
|
|
155
|
+
if (!org?.slack_bot_token || !org?.slack_system_channel_id) return;
|
|
156
|
+
|
|
157
|
+
await slackApi(org.slack_bot_token, "chat.postMessage", {
|
|
158
|
+
channel: org.slack_system_channel_id,
|
|
159
|
+
text,
|
|
160
|
+
});
|
|
161
|
+
} catch {
|
|
162
|
+
// Non-fatal
|
|
163
|
+
}
|
|
107
164
|
}
|
package/src/web/app.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getUserByEmail,
|
|
10
10
|
upsertUser,
|
|
11
11
|
setOrgSlack,
|
|
12
|
+
getSessionEvents,
|
|
12
13
|
type Sql,
|
|
13
14
|
} from "../service/db";
|
|
14
15
|
import { layout, nav } from "./layout";
|
|
@@ -155,15 +156,33 @@ export function createApp(sql: Sql) {
|
|
|
155
156
|
const org = await getOrg(sql, payload.org_id);
|
|
156
157
|
if (!org) return c.redirect("/login");
|
|
157
158
|
|
|
158
|
-
//
|
|
159
|
-
|
|
159
|
+
// Detect setup status from system events
|
|
160
|
+
let cliInstalled = false;
|
|
161
|
+
const devices: Array<{ name: string; lastSeen: string; os: string; activeSession?: string }> = [];
|
|
162
|
+
try {
|
|
163
|
+
const systemEvents = await getSessionEvents(sql, payload.org_id, "_system", "_system");
|
|
164
|
+
for (const e of systemEvents) {
|
|
165
|
+
const text = (e.payload as { stop_response?: string }).stop_response ?? "";
|
|
166
|
+
const match = text.match(/^Device connected: (.+) \((.+)\)$/);
|
|
167
|
+
if (match) {
|
|
168
|
+
cliInstalled = true;
|
|
169
|
+
// Deduplicate by device name, keep latest
|
|
170
|
+
const existing = devices.findIndex((d) => d.name === match[1]);
|
|
171
|
+
const device = { name: match[1], os: match[2], lastSeen: e.timestamp };
|
|
172
|
+
if (existing >= 0) devices[existing] = device;
|
|
173
|
+
else devices.push(device);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch { /* _system project may not exist yet */ }
|
|
177
|
+
|
|
160
178
|
const ctx = {
|
|
161
179
|
token,
|
|
162
180
|
userName: payload.name,
|
|
163
181
|
orgName: org.name,
|
|
182
|
+
orgSlug: org.slug,
|
|
164
183
|
email: payload.email,
|
|
165
184
|
slackConnected: !!org.slack_team_id,
|
|
166
|
-
cliInstalled
|
|
185
|
+
cliInstalled,
|
|
167
186
|
hasConnectedSession: false,
|
|
168
187
|
};
|
|
169
188
|
|
|
@@ -171,9 +190,9 @@ export function createApp(sql: Sql) {
|
|
|
171
190
|
const activeSessions: unknown[] = [];
|
|
172
191
|
|
|
173
192
|
if (activeSessions.length > 0) {
|
|
174
|
-
return layout(renderActiveView(ctx, [], []), "Polaris");
|
|
193
|
+
return layout(renderActiveView(ctx, [], [], devices), "Polaris");
|
|
175
194
|
}
|
|
176
|
-
return layout(renderSetupView(ctx), "Polaris");
|
|
195
|
+
return layout(renderSetupView(ctx, devices), "Polaris");
|
|
177
196
|
});
|
|
178
197
|
|
|
179
198
|
// --- Profile ---
|
|
@@ -192,6 +211,7 @@ export function createApp(sql: Sql) {
|
|
|
192
211
|
token,
|
|
193
212
|
userName: payload.name,
|
|
194
213
|
orgName: org.name,
|
|
214
|
+
orgSlug: org.slug,
|
|
195
215
|
email: payload.email,
|
|
196
216
|
slackConnected: !!org.slack_team_id,
|
|
197
217
|
cliInstalled: false,
|
|
@@ -205,9 +225,9 @@ export function createApp(sql: Sql) {
|
|
|
205
225
|
|
|
206
226
|
app.get("/preview", (c) => {
|
|
207
227
|
const mockToken = "preview-token";
|
|
208
|
-
const base = { token: mockToken, userName: mockUser.name, orgName: mockOrg.name, email: mockUser.email };
|
|
228
|
+
const base = { token: mockToken, userName: mockUser.name, orgName: mockOrg.name, orgSlug: "lightup-data" as string | null, email: mockUser.email };
|
|
209
229
|
|
|
210
|
-
const fresh = { ...base, slackConnected: false, cliInstalled: false, hasConnectedSession: false };
|
|
230
|
+
const fresh = { ...base, orgSlug: null, slackConnected: false, cliInstalled: false, hasConnectedSession: false };
|
|
211
231
|
const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false };
|
|
212
232
|
const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false };
|
|
213
233
|
const allDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: true };
|
|
@@ -333,7 +353,7 @@ export function createApp(sql: Sql) {
|
|
|
333
353
|
redirect_uri: process.env.SLACK_REDIRECT_URI ?? "http://localhost:3000/slack/callback",
|
|
334
354
|
}),
|
|
335
355
|
});
|
|
336
|
-
const slackData = (await slackRes.json()) as { ok: boolean; team?: { id: string }; access_token?: string; error?: string };
|
|
356
|
+
const slackData = (await slackRes.json()) as { ok: boolean; team?: { id: string; name?: string }; access_token?: string; error?: string };
|
|
337
357
|
|
|
338
358
|
if (!slackData.ok) {
|
|
339
359
|
// Check if Slack is already connected (e.g., stale callback reload)
|
|
@@ -348,20 +368,99 @@ export function createApp(sql: Sql) {
|
|
|
348
368
|
let systemChannelId: string | undefined;
|
|
349
369
|
try {
|
|
350
370
|
systemChannelId = await createSystemChannel(slackData.access_token!, payload.email);
|
|
351
|
-
await postSystemEvent(
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
371
|
+
await postSystemEvent({
|
|
372
|
+
sql,
|
|
373
|
+
orgId: payload.org_id,
|
|
374
|
+
sender: payload.participant_id,
|
|
375
|
+
text: `:star: *${payload.name}* connected this Slack workspace to Polaris`,
|
|
376
|
+
context: `Organization: ${payload.org_id}`,
|
|
377
|
+
botToken: slackData.access_token!,
|
|
378
|
+
channelId: systemChannelId,
|
|
379
|
+
});
|
|
357
380
|
} catch {
|
|
358
381
|
// Non-fatal — Slack is connected even if channel creation fails
|
|
359
382
|
}
|
|
360
383
|
|
|
361
|
-
|
|
384
|
+
// Set org slug from Slack workspace name if not already set
|
|
385
|
+
const currentOrg = await getOrg(sql, payload.org_id);
|
|
386
|
+
const slug = currentOrg?.slug ? undefined : (slackData.team?.name?.toLowerCase().replace(/\s+/g, "-") ?? undefined);
|
|
387
|
+
await setOrgSlack(sql, payload.org_id, slackData.team!.id, slackData.access_token!, systemChannelId, slug);
|
|
388
|
+
notifyDashboard(payload.org_id);
|
|
362
389
|
return c.redirect(`/dashboard?token=${state}`);
|
|
363
390
|
});
|
|
364
391
|
|
|
392
|
+
// --- Dashboard SSE (real-time status updates) ---
|
|
393
|
+
|
|
394
|
+
const dashboardListeners = new Map<string, Set<ReadableStreamDefaultController<Uint8Array>>>(); // orgId → controllers
|
|
395
|
+
|
|
396
|
+
function notifyDashboard(orgId: string) {
|
|
397
|
+
const controllers = dashboardListeners.get(orgId);
|
|
398
|
+
if (!controllers) return;
|
|
399
|
+
const data = `data: refresh\n\n`;
|
|
400
|
+
const bytes = new TextEncoder().encode(data);
|
|
401
|
+
for (const ctrl of controllers) {
|
|
402
|
+
try { ctrl.enqueue(bytes); } catch { controllers.delete(ctrl); }
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Expose notifyDashboard so other parts of the app can trigger it
|
|
407
|
+
(app as unknown as { notifyDashboard: typeof notifyDashboard }).notifyDashboard = notifyDashboard;
|
|
408
|
+
|
|
409
|
+
app.get("/api/dashboard-events", async (c) => {
|
|
410
|
+
const token = c.req.query("token");
|
|
411
|
+
if (!token) return c.json({ error: "No token" }, 400);
|
|
412
|
+
const payload = await verifyToken(token);
|
|
413
|
+
if (!payload) return c.json({ error: "Invalid token" }, 401);
|
|
414
|
+
|
|
415
|
+
const orgId = payload.org_id;
|
|
416
|
+
let controller: ReadableStreamDefaultController<Uint8Array>;
|
|
417
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
418
|
+
start(ctrl) {
|
|
419
|
+
controller = ctrl;
|
|
420
|
+
if (!dashboardListeners.has(orgId)) dashboardListeners.set(orgId, new Set());
|
|
421
|
+
dashboardListeners.get(orgId)!.add(controller);
|
|
422
|
+
},
|
|
423
|
+
cancel() {
|
|
424
|
+
dashboardListeners.get(orgId)?.delete(controller);
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
return new Response(stream, {
|
|
429
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
app.post("/api/notify-dashboard", async (c) => {
|
|
434
|
+
const auth = c.req.header("Authorization");
|
|
435
|
+
if (!auth?.startsWith("Bearer ")) return c.json({ error: "Unauthorized" }, 401);
|
|
436
|
+
const payload = await verifyToken(auth.slice(7));
|
|
437
|
+
if (!payload) return c.json({ error: "Invalid token" }, 401);
|
|
438
|
+
notifyDashboard(payload.org_id);
|
|
439
|
+
return c.json({ ok: true });
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
app.get("/api/dashboard-status", async (c) => {
|
|
443
|
+
const token = c.req.query("token");
|
|
444
|
+
if (!token) return c.json({ error: "No token" }, 400);
|
|
445
|
+
const payload = await verifyToken(token);
|
|
446
|
+
if (!payload) return c.json({ error: "Invalid token" }, 401);
|
|
447
|
+
|
|
448
|
+
const org = await getOrg(sql, payload.org_id);
|
|
449
|
+
const slackConnected = !!org?.slack_team_id;
|
|
450
|
+
|
|
451
|
+
let cliInstalled = false;
|
|
452
|
+
try {
|
|
453
|
+
const systemEvents = await getSessionEvents(sql, payload.org_id, "_system", "_system");
|
|
454
|
+
cliInstalled = systemEvents.some((e) =>
|
|
455
|
+
(e.payload as { stop_response?: string }).stop_response?.startsWith("Device connected:")
|
|
456
|
+
);
|
|
457
|
+
} catch { /* _system may not exist */ }
|
|
458
|
+
|
|
459
|
+
const hasConnectedSession = false; // TODO
|
|
460
|
+
|
|
461
|
+
return c.json({ slackConnected, cliInstalled, hasConnectedSession });
|
|
462
|
+
});
|
|
463
|
+
|
|
365
464
|
// --- CLI auth flow ---
|
|
366
465
|
|
|
367
466
|
// CLI calls this with a local callback port. We redirect to Google SSO
|
package/src/web/views.ts
CHANGED
|
@@ -8,6 +8,7 @@ interface ViewContext {
|
|
|
8
8
|
token: string;
|
|
9
9
|
userName: string;
|
|
10
10
|
orgName: string;
|
|
11
|
+
orgSlug: string | null;
|
|
11
12
|
email: string;
|
|
12
13
|
slackConnected: boolean;
|
|
13
14
|
cliInstalled: boolean;
|
|
@@ -77,39 +78,56 @@ function renderFloorSection(ctx: ViewContext, compact = false, state: StepState
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
if (ctx.slackConnected) {
|
|
81
|
+
const slugRow = ctx.orgSlug
|
|
82
|
+
? `<div class="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between">
|
|
83
|
+
<div>
|
|
84
|
+
<p class="text-xs text-gray-400">Org identifier</p>
|
|
85
|
+
<p class="text-sm font-mono text-gray-700">${ctx.orgSlug}</p>
|
|
86
|
+
</div>
|
|
87
|
+
<form action="/settings/slug" method="POST" class="flex items-center gap-2">
|
|
88
|
+
<input type="hidden" name="token" value="${ctx.token}">
|
|
89
|
+
<input type="text" name="slug" value="${ctx.orgSlug}" class="px-2 py-1 border border-gray-200 rounded text-xs font-mono w-40 focus:ring-polaris-500 focus:border-polaris-500 outline-none hidden" id="slug-edit-${ctx.token.slice(-6)}">
|
|
90
|
+
<button type="button" onclick="const i=this.previousElementSibling;i.classList.toggle('hidden');if(!i.classList.contains('hidden'))i.focus()" class="text-xs text-gray-400 hover:text-gray-600">Edit</button>
|
|
91
|
+
</form>
|
|
92
|
+
</div>`
|
|
93
|
+
: `<div class="mt-3 pt-3 border-t border-gray-100">
|
|
94
|
+
<p class="text-xs text-gray-400">Org identifier: <span class="text-gray-500">not set (will be set from first floor connection)</span></p>
|
|
95
|
+
</div>`;
|
|
96
|
+
|
|
80
97
|
return `
|
|
81
98
|
<div>
|
|
82
|
-
|
|
99
|
+
<div class="flex items-baseline gap-2 mb-3">
|
|
100
|
+
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Floor</h2>
|
|
101
|
+
${statusBadge("Connected", true)}
|
|
102
|
+
</div>
|
|
83
103
|
<div class="bg-white border border-gray-200 rounded-lg p-5">
|
|
84
104
|
<div class="flex items-center gap-3">
|
|
85
105
|
<div class="w-10 h-10 rounded-lg bg-[#4A154B] flex items-center justify-center shrink-0">
|
|
86
106
|
${slackIcon.replace('class="w-4 h-4"', 'class="w-5 h-5 text-white"')}
|
|
87
107
|
</div>
|
|
88
108
|
<div>
|
|
89
|
-
<
|
|
90
|
-
<p class="text-sm font-semibold text-gray-900">Slack</p>
|
|
91
|
-
${statusBadge("Connected", true)}
|
|
92
|
-
</div>
|
|
109
|
+
<p class="text-sm font-semibold text-gray-900">Slack</p>
|
|
93
110
|
<p class="text-sm text-gray-500 mt-0.5">Workspace linked. Channels are auto-created for your projects.</p>
|
|
94
111
|
</div>
|
|
95
112
|
</div>
|
|
113
|
+
${slugRow}
|
|
96
114
|
</div>
|
|
97
115
|
</div>`;
|
|
98
116
|
}
|
|
99
117
|
|
|
100
118
|
return sectionWrap(state, `
|
|
101
119
|
<div>
|
|
102
|
-
|
|
120
|
+
<div class="flex items-baseline gap-2 mb-3">
|
|
121
|
+
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Floor</h2>
|
|
122
|
+
${statusBadge("Not connected", false)}
|
|
123
|
+
</div>
|
|
103
124
|
<div class="bg-white border ${state === "active" ? cardClass("active") : "border-amber-200"} rounded-lg p-5">
|
|
104
125
|
<div class="flex items-center gap-3">
|
|
105
126
|
<div class="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
|
|
106
127
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
|
|
107
128
|
</div>
|
|
108
129
|
<div>
|
|
109
|
-
<
|
|
110
|
-
<p class="text-sm font-semibold text-gray-900">Slack</p>
|
|
111
|
-
${statusBadge("Not connected", false)}
|
|
112
|
-
</div>
|
|
130
|
+
<p class="text-sm font-semibold text-gray-900">Slack</p>
|
|
113
131
|
<p class="text-sm text-gray-500 mt-0.5">Connect your Slack workspace to enable the floor for your team.</p>
|
|
114
132
|
</div>
|
|
115
133
|
</div>
|
|
@@ -127,7 +145,17 @@ function renderDevicesSection(ctx: ViewContext, devices: DeviceFixture[], state:
|
|
|
127
145
|
if (devices.length > 0) {
|
|
128
146
|
return `
|
|
129
147
|
<div>
|
|
130
|
-
|
|
148
|
+
<div class="flex items-baseline gap-2 mb-3">
|
|
149
|
+
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Devices</h2>
|
|
150
|
+
${statusBadge(`${devices.length} connected`, true)}
|
|
151
|
+
</div>
|
|
152
|
+
<details class="mb-3">
|
|
153
|
+
<summary class="text-xs text-polaris-700 hover:text-polaris-800 font-medium cursor-pointer select-none">+ Add another device</summary>
|
|
154
|
+
<div class="mt-2 bg-white border border-gray-200 rounded-lg p-4">
|
|
155
|
+
<p class="text-sm text-gray-500">Run on any new machine:</p>
|
|
156
|
+
${copyBlock("npx @lightupai/polaris login")}
|
|
157
|
+
</div>
|
|
158
|
+
</details>
|
|
131
159
|
<div class="bg-white border border-gray-200 rounded-lg divide-y divide-gray-100">
|
|
132
160
|
${devices.map((d) => renderDeviceRow(d)).join("")}
|
|
133
161
|
</div>
|
|
@@ -137,24 +165,21 @@ function renderDevicesSection(ctx: ViewContext, devices: DeviceFixture[], state:
|
|
|
137
165
|
// Setup state — no devices yet
|
|
138
166
|
return sectionWrap(state, `
|
|
139
167
|
<div>
|
|
140
|
-
|
|
168
|
+
<div class="flex items-baseline gap-2 mb-3">
|
|
169
|
+
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Devices</h2>
|
|
170
|
+
${ctx.cliInstalled ? statusBadge("Installed", true) : statusBadge("Not installed", false)}
|
|
171
|
+
</div>
|
|
141
172
|
<div class="bg-white border ${state === "active" ? cardClass("active") : "border-gray-200"} rounded-lg p-5">
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
</div>
|
|
146
|
-
<p class="text-sm text-gray-500 mt-1">${ctx.cliInstalled
|
|
147
|
-
? "Polaris is set up. Run the same command on other machines to add them."
|
|
148
|
-
: "Run this in your terminal. Repeat on each machine you work from."}</p>
|
|
149
|
-
${ctx.cliInstalled
|
|
150
|
-
? ""
|
|
151
|
-
: copyBlock("npx @lightup/polaris login")}
|
|
173
|
+
<p class="text-sm font-semibold text-gray-900">${ctx.cliInstalled ? "Add another device" : "Set up Polaris on your first device"}</p>
|
|
174
|
+
<p class="text-sm text-gray-500 mt-1">Run this in your terminal${ctx.cliInstalled ? " on any new machine" : ". Repeat on each machine you work from"}.</p>
|
|
175
|
+
${copyBlock("npx @lightupai/polaris login")}
|
|
152
176
|
</div>
|
|
153
177
|
</div>`);
|
|
154
178
|
}
|
|
155
179
|
|
|
156
180
|
function renderDeviceRow(device: DeviceFixture): string {
|
|
157
|
-
const
|
|
181
|
+
const recentThreshold = Date.now() - 60 * 60 * 1000; // 1 hour
|
|
182
|
+
const isOnline = device.activeSession || new Date(device.lastSeen).getTime() > recentThreshold;
|
|
158
183
|
return `
|
|
159
184
|
<div class="p-4 flex items-center justify-between">
|
|
160
185
|
<div class="flex items-center gap-3">
|
|
@@ -172,10 +197,8 @@ function renderDeviceRow(device: DeviceFixture): string {
|
|
|
172
197
|
</div>
|
|
173
198
|
</div>
|
|
174
199
|
<div class="text-right">
|
|
175
|
-
${
|
|
176
|
-
|
|
177
|
-
<p class="text-xs text-gray-400">Active now</p>`
|
|
178
|
-
: `<p class="text-xs text-gray-400">Last seen ${new Date(device.lastSeen).toLocaleDateString()}</p>`}
|
|
200
|
+
${device.activeSession ? `<p class="text-xs font-medium text-gray-700">${device.activeSession}</p>` : ""}
|
|
201
|
+
<p class="text-xs text-gray-400">${isOnline ? "Online" : "Offline"} · ${new Date(device.lastSeen).toLocaleString()}</p>
|
|
179
202
|
</div>
|
|
180
203
|
</div>`;
|
|
181
204
|
}
|
|
@@ -187,6 +210,13 @@ function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixtur
|
|
|
187
210
|
return `
|
|
188
211
|
<div>
|
|
189
212
|
${sectionHeader("Projects & Sessions")}
|
|
213
|
+
<details class="mb-3">
|
|
214
|
+
<summary class="text-xs text-polaris-700 hover:text-polaris-800 font-medium cursor-pointer select-none">+ Join another session</summary>
|
|
215
|
+
<div class="mt-2 bg-white border border-gray-200 rounded-lg p-4">
|
|
216
|
+
<p class="text-sm text-gray-500">Inside your AI agent, run:</p>
|
|
217
|
+
${copyBlock("/polaris join <project> <session>")}
|
|
218
|
+
</div>
|
|
219
|
+
</details>
|
|
190
220
|
<div class="space-y-3">
|
|
191
221
|
${sessions.map((s) => renderSessionCard(s, ctx.userName)).join("")}
|
|
192
222
|
</div>
|
|
@@ -200,12 +230,12 @@ function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixtur
|
|
|
200
230
|
// Setup state — no sessions yet
|
|
201
231
|
return sectionWrap(state, `
|
|
202
232
|
<div>
|
|
203
|
-
|
|
233
|
+
<div class="flex items-baseline gap-2 mb-3">
|
|
234
|
+
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Projects & Sessions</h2>
|
|
235
|
+
${ctx.hasConnectedSession ? statusBadge("Connected", true) : statusBadge("Waiting", false)}
|
|
236
|
+
</div>
|
|
204
237
|
<div class="bg-white border ${state === "active" ? cardClass("active") : "border-gray-200"} rounded-lg p-5">
|
|
205
|
-
<
|
|
206
|
-
<p class="text-sm font-semibold text-gray-900">Connect your first session</p>
|
|
207
|
-
${ctx.hasConnectedSession ? statusBadge("Connected", true) : statusBadge("Waiting", false)}
|
|
208
|
-
</div>
|
|
238
|
+
<p class="text-sm font-semibold text-gray-900">Connect your first session</p>
|
|
209
239
|
<p class="text-sm text-gray-500 mt-1">${ctx.hasConnectedSession
|
|
210
240
|
? "You've connected a session. You're ready to collaborate."
|
|
211
241
|
: "Inside your AI agent (Claude Code, Cursor, etc.), run:"}</p>
|
|
@@ -264,6 +294,20 @@ function renderProjectCard(project: ProjectFixture): string {
|
|
|
264
294
|
</div>`;
|
|
265
295
|
}
|
|
266
296
|
|
|
297
|
+
// --- Auto-refresh script ---
|
|
298
|
+
|
|
299
|
+
function autoRefreshScript(token: string): string {
|
|
300
|
+
return `
|
|
301
|
+
<script>
|
|
302
|
+
(function() {
|
|
303
|
+
const evtSource = new EventSource('/api/dashboard-events?token=${token}');
|
|
304
|
+
evtSource.onmessage = function(e) {
|
|
305
|
+
if (e.data === 'refresh') window.location.reload();
|
|
306
|
+
};
|
|
307
|
+
})();
|
|
308
|
+
</script>`;
|
|
309
|
+
}
|
|
310
|
+
|
|
267
311
|
// --- Setup view (zero state) ---
|
|
268
312
|
// Same three sections, but each shows its setup prompt instead of live data.
|
|
269
313
|
|
|
@@ -284,7 +328,8 @@ export function renderSetupView(ctx: ViewContext, devices: DeviceFixture[] = [])
|
|
|
284
328
|
${renderFloorSection(ctx, false, stepState("floor"))}
|
|
285
329
|
${renderDevicesSection(ctx, ctx.cliInstalled ? devices : [], stepState("devices"))}
|
|
286
330
|
${renderProjectsSessionsSection(ctx, [], [], stepState("sessions"))}
|
|
287
|
-
</div
|
|
331
|
+
</div>
|
|
332
|
+
${autoRefreshScript(ctx.token)}`;
|
|
288
333
|
}
|
|
289
334
|
|
|
290
335
|
// --- Error view ---
|
|
@@ -313,7 +358,8 @@ export function renderActiveView(ctx: ViewContext, sessions: SessionFixture[], p
|
|
|
313
358
|
${renderFloorSection(ctx, true)}
|
|
314
359
|
${renderDevicesSection(ctx, devices)}
|
|
315
360
|
${renderProjectsSessionsSection(ctx, sessions, projects)}
|
|
316
|
-
</div
|
|
361
|
+
</div>
|
|
362
|
+
${autoRefreshScript(ctx.token)}`;
|
|
317
363
|
}
|
|
318
364
|
|
|
319
365
|
// --- Profile view ---
|
package/tests/client.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { startServer } from "../src/service/server";
|
|
|
3
3
|
import { startDaemon } from "../src/daemon/daemon";
|
|
4
4
|
import type { Sql } from "../src/service/db";
|
|
5
5
|
|
|
6
|
-
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/
|
|
6
|
+
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
|
|
7
7
|
|
|
8
8
|
let serviceUrl: string;
|
|
9
9
|
let daemonUrl: string;
|
|
@@ -34,7 +34,7 @@ beforeEach(async () => {
|
|
|
34
34
|
await sql`DROP TABLE IF EXISTS projects`;
|
|
35
35
|
await sql`DROP TABLE IF EXISTS users`;
|
|
36
36
|
await sql`DROP TABLE IF EXISTS orgs`;
|
|
37
|
-
await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
37
|
+
await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
38
38
|
await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
39
39
|
await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
|
|
40
40
|
await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
|
package/tests/daemon.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { startDaemon } from "../src/daemon/daemon";
|
|
|
3
3
|
import { startServer } from "../src/service/server";
|
|
4
4
|
import type { Sql } from "../src/service/db";
|
|
5
5
|
|
|
6
|
-
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/
|
|
6
|
+
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
|
|
7
7
|
|
|
8
8
|
let daemonUrl: string;
|
|
9
9
|
let serviceUrl: string;
|
|
@@ -36,7 +36,7 @@ beforeEach(async () => {
|
|
|
36
36
|
await sql`DROP TABLE IF EXISTS projects`;
|
|
37
37
|
await sql`DROP TABLE IF EXISTS users`;
|
|
38
38
|
await sql`DROP TABLE IF EXISTS orgs`;
|
|
39
|
-
await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
39
|
+
await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
40
40
|
await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
41
41
|
await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
|
|
42
42
|
await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
|
package/tests/db.test.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
} from "../src/service/db";
|
|
24
24
|
import type { PolarisEvent } from "../src/types";
|
|
25
25
|
|
|
26
|
-
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/
|
|
26
|
+
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
|
|
27
27
|
|
|
28
28
|
let sql: Sql;
|
|
29
29
|
|
package/tests/e2e.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { startServer } from "../src/service/server";
|
|
|
3
3
|
import { startDaemon } from "../src/daemon/daemon";
|
|
4
4
|
import type { Sql } from "../src/service/db";
|
|
5
5
|
|
|
6
|
-
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/
|
|
6
|
+
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
|
|
7
7
|
|
|
8
8
|
let serviceUrl: string;
|
|
9
9
|
let daemonUrl: string;
|
|
@@ -34,7 +34,7 @@ beforeEach(async () => {
|
|
|
34
34
|
await sql`DROP TABLE IF EXISTS projects`;
|
|
35
35
|
await sql`DROP TABLE IF EXISTS users`;
|
|
36
36
|
await sql`DROP TABLE IF EXISTS orgs`;
|
|
37
|
-
await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
37
|
+
await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
38
38
|
await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
39
39
|
await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
|
|
40
40
|
await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
|
package/tests/service.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { startServer } from "../src/service/server";
|
|
|
3
3
|
import type { Sql } from "../src/service/db";
|
|
4
4
|
import type { Server } from "bun";
|
|
5
5
|
|
|
6
|
-
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/
|
|
6
|
+
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
|
|
7
7
|
|
|
8
8
|
let base: string;
|
|
9
9
|
let sql: Sql;
|
|
@@ -26,7 +26,7 @@ beforeEach(async () => {
|
|
|
26
26
|
await sql`DROP TABLE IF EXISTS projects`;
|
|
27
27
|
await sql`DROP TABLE IF EXISTS users`;
|
|
28
28
|
await sql`DROP TABLE IF EXISTS orgs`;
|
|
29
|
-
await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
29
|
+
await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
30
30
|
await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
31
31
|
await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
|
|
32
32
|
await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
|
package/tests/web.test.ts
CHANGED
|
@@ -13,12 +13,12 @@ import { createApp } from "../src/web/app";
|
|
|
13
13
|
import { createDb, createOrg, createUser, type Sql } from "../src/service/db";
|
|
14
14
|
import { createToken } from "../src/service/auth";
|
|
15
15
|
|
|
16
|
-
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/
|
|
16
|
+
const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
|
|
17
17
|
|
|
18
18
|
// --- View context helpers ---
|
|
19
19
|
|
|
20
|
-
const base = { token: "test-token", userName: "Manu Bansal", orgName: "Lightup", email: "manu@lightup.ai" };
|
|
21
|
-
const fresh = { ...base, slackConnected: false, cliInstalled: false, hasConnectedSession: false };
|
|
20
|
+
const base = { token: "test-token", userName: "Manu Bansal", orgName: "Lightup", orgSlug: "lightup-data" as string | null, email: "manu@lightup.ai" };
|
|
21
|
+
const fresh = { ...base, orgSlug: null, slackConnected: false, cliInstalled: false, hasConnectedSession: false };
|
|
22
22
|
const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false };
|
|
23
23
|
const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false };
|
|
24
24
|
const allDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: true };
|
|
@@ -35,7 +35,7 @@ describe("renderSetupView", () => {
|
|
|
35
35
|
// Connect Slack button is present
|
|
36
36
|
expect(html).toContain("Connect Slack");
|
|
37
37
|
// Install CLI command is present
|
|
38
|
-
expect(html).toContain("npx @
|
|
38
|
+
expect(html).toContain("npx @lightupai/polaris login");
|
|
39
39
|
// Connect session command is present
|
|
40
40
|
expect(html).toContain("/polaris join my-project my-session");
|
|
41
41
|
});
|
|
@@ -52,15 +52,14 @@ describe("renderSetupView", () => {
|
|
|
52
52
|
const highlightIdx = html.indexOf("border-polaris-300");
|
|
53
53
|
expect(highlightIdx).toBeGreaterThan(devicesIdx);
|
|
54
54
|
// Install CLI command present
|
|
55
|
-
expect(html).toContain("npx @
|
|
55
|
+
expect(html).toContain("npx @lightupai/polaris login");
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
test("cli done: floor and devices done, sessions is highlighted", () => {
|
|
59
59
|
const html = renderSetupView(cliDone, mockDevices);
|
|
60
|
-
// Device list is shown
|
|
60
|
+
// Device list is shown with "add another" tray
|
|
61
61
|
expect(html).toContain("Manu's MacBook Pro");
|
|
62
|
-
|
|
63
|
-
expect(html).not.toContain("npx @lightup/polaris login");
|
|
62
|
+
expect(html).toContain("Add another device");
|
|
64
63
|
// Session section has highlight
|
|
65
64
|
const sessIdx = html.indexOf("Projects & Sessions");
|
|
66
65
|
const lastHighlight = html.lastIndexOf("border-polaris-300");
|
|
@@ -119,9 +118,9 @@ describe("renderActiveView", () => {
|
|
|
119
118
|
const html = renderActiveView(allDone, mockActiveSessions, mockProjects, mockDevices);
|
|
120
119
|
expect(html).toContain("Manu's MacBook Pro");
|
|
121
120
|
expect(html).toContain("Manu's iMac");
|
|
122
|
-
expect(html).toContain("
|
|
121
|
+
expect(html).toContain("Online");
|
|
123
122
|
expect(html).toContain("polaris/auth");
|
|
124
|
-
expect(html).toContain("
|
|
123
|
+
expect(html).toContain("Offline");
|
|
125
124
|
});
|
|
126
125
|
|
|
127
126
|
test("hides devices section when no devices", () => {
|
|
@@ -269,7 +268,7 @@ describe("routes", () => {
|
|
|
269
268
|
await sql`DROP TABLE IF EXISTS projects`;
|
|
270
269
|
await sql`DROP TABLE IF EXISTS users`;
|
|
271
270
|
await sql`DROP TABLE IF EXISTS orgs`;
|
|
272
|
-
await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
271
|
+
await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
273
272
|
await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
|
|
274
273
|
await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
|
|
275
274
|
await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
|