@online5880/opensession 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/package.json +20 -0
- package/sql/schema.sql +27 -0
- package/src/cli.js +172 -0
- package/src/config.js +32 -0
- package/src/supabase.js +119 -0
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @online5880/opensession
|
|
2
|
+
|
|
3
|
+
MVP CLI for session continuity with Supabase.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
- `init --url --anon-key [--project-key] [--actor]`
|
|
8
|
+
- `login --actor`
|
|
9
|
+
- `start --project-key [--project-name] [--actor]`
|
|
10
|
+
- `resume --session-id [--actor]`
|
|
11
|
+
- `status [--project-key]`
|
|
12
|
+
- `log [--session-id] [--limit]`
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install
|
|
18
|
+
node src/cli.js init --url "$SUPABASE_URL" --anon-key "$SUPABASE_ANON_KEY" --project-key demo --actor mane
|
|
19
|
+
node src/cli.js start --project-key demo
|
|
20
|
+
node src/cli.js status
|
|
21
|
+
node src/cli.js log
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Apply `sql/schema.sql` in Supabase SQL editor before using the CLI.
|
|
25
|
+
|
|
26
|
+
## npx 실행
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx @online5880/opensession init
|
|
30
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@online5880/opensession",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Session continuity bridge CLI with Supabase backend",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"opensession": "src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node src/cli.js",
|
|
11
|
+
"lint": "sh -c 'for f in src/*.js; do node --check \"$f\"; done'"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@supabase/supabase-js": "^2.52.1",
|
|
18
|
+
"commander": "^14.0.1"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/sql/schema.sql
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
create table if not exists projects (
|
|
2
|
+
id uuid primary key default gen_random_uuid(),
|
|
3
|
+
project_key text unique not null,
|
|
4
|
+
name text not null,
|
|
5
|
+
created_at timestamptz not null default now()
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
create table if not exists sessions (
|
|
9
|
+
id uuid primary key default gen_random_uuid(),
|
|
10
|
+
project_id uuid not null references projects(id) on delete cascade,
|
|
11
|
+
actor text not null,
|
|
12
|
+
status text not null default 'active',
|
|
13
|
+
started_at timestamptz not null default now(),
|
|
14
|
+
ended_at timestamptz
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
create index if not exists sessions_project_status_idx on sessions(project_id, status);
|
|
18
|
+
|
|
19
|
+
create table if not exists session_events (
|
|
20
|
+
id uuid primary key default gen_random_uuid(),
|
|
21
|
+
session_id uuid not null references sessions(id) on delete cascade,
|
|
22
|
+
type text not null,
|
|
23
|
+
payload jsonb not null default '{}'::jsonb,
|
|
24
|
+
created_at timestamptz not null default now()
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
create index if not exists session_events_session_created_idx on session_events(session_id, created_at);
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import {
|
|
5
|
+
appendEvent,
|
|
6
|
+
ensureProject,
|
|
7
|
+
getClient,
|
|
8
|
+
getSession,
|
|
9
|
+
getSessionEvents,
|
|
10
|
+
listActiveSessions,
|
|
11
|
+
startSession
|
|
12
|
+
} from './supabase.js';
|
|
13
|
+
import { getConfigPath, mergeConfig, readConfig, writeConfig } from './config.js';
|
|
14
|
+
|
|
15
|
+
const program = new Command();
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.name('opensession')
|
|
19
|
+
.description('Session continuity bridge CLI for Supabase')
|
|
20
|
+
.version('0.1.0');
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.command('init')
|
|
24
|
+
.description('Initialize CLI config')
|
|
25
|
+
.requiredOption('--url <url>', 'Supabase project URL')
|
|
26
|
+
.requiredOption('--anon-key <anonKey>', 'Supabase anon key')
|
|
27
|
+
.option('--project-key <projectKey>', 'Default project key')
|
|
28
|
+
.option('--actor <actor>', 'Default actor/username')
|
|
29
|
+
.action(async (options) => {
|
|
30
|
+
const current = await readConfig();
|
|
31
|
+
const next = mergeConfig(current, {
|
|
32
|
+
supabaseUrl: options.url,
|
|
33
|
+
supabaseAnonKey: options.anonKey,
|
|
34
|
+
defaultProjectKey: options.projectKey ?? current.defaultProjectKey,
|
|
35
|
+
actor: options.actor ?? current.actor
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const path = await writeConfig(next);
|
|
39
|
+
console.log(`Config saved: ${path}`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
program
|
|
43
|
+
.command('login')
|
|
44
|
+
.description('Save actor identity used in session events')
|
|
45
|
+
.requiredOption('--actor <actor>', 'Actor/username')
|
|
46
|
+
.action(async (options) => {
|
|
47
|
+
const current = await readConfig();
|
|
48
|
+
const next = mergeConfig(current, { actor: options.actor });
|
|
49
|
+
await writeConfig(next);
|
|
50
|
+
console.log(`Logged in as ${options.actor}`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
program
|
|
54
|
+
.command('start')
|
|
55
|
+
.description('Start a new session and emit a start event')
|
|
56
|
+
.requiredOption('--project-key <projectKey>', 'Project key')
|
|
57
|
+
.option('--project-name <projectName>', 'Project display name')
|
|
58
|
+
.option('--actor <actor>', 'Actor override')
|
|
59
|
+
.action(async (options) => {
|
|
60
|
+
const config = await readConfig();
|
|
61
|
+
const client = getClient(config);
|
|
62
|
+
const actor = options.actor ?? config.actor ?? 'anonymous';
|
|
63
|
+
|
|
64
|
+
const project = await ensureProject(client, options.projectKey, options.projectName);
|
|
65
|
+
const session = await startSession(client, project.id, actor);
|
|
66
|
+
|
|
67
|
+
await writeConfig(
|
|
68
|
+
mergeConfig(config, {
|
|
69
|
+
defaultProjectKey: options.projectKey,
|
|
70
|
+
lastSessionId: session.id
|
|
71
|
+
})
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
console.log(`Session started: ${session.id}`);
|
|
75
|
+
console.log(`Project: ${project.project_key}`);
|
|
76
|
+
console.log(`Actor: ${session.actor}`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
program
|
|
80
|
+
.command('resume')
|
|
81
|
+
.description('Resume an existing session by emitting a resumed event')
|
|
82
|
+
.requiredOption('--session-id <sessionId>', 'Session id to resume')
|
|
83
|
+
.option('--actor <actor>', 'Actor override')
|
|
84
|
+
.action(async (options) => {
|
|
85
|
+
const config = await readConfig();
|
|
86
|
+
const client = getClient(config);
|
|
87
|
+
const actor = options.actor ?? config.actor ?? 'anonymous';
|
|
88
|
+
const session = await getSession(client, options.sessionId);
|
|
89
|
+
|
|
90
|
+
if (!session) {
|
|
91
|
+
throw new Error(`Session not found: ${options.sessionId}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const event = await appendEvent(client, session.id, 'resumed', { actor });
|
|
95
|
+
await writeConfig(mergeConfig(config, { lastSessionId: session.id }));
|
|
96
|
+
|
|
97
|
+
console.log(`Session resumed: ${session.id}`);
|
|
98
|
+
console.log(`Event: ${event.id}`);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
program
|
|
102
|
+
.command('status')
|
|
103
|
+
.description('Show active sessions for a project')
|
|
104
|
+
.option('--project-key <projectKey>', 'Project key (defaults to configured project key)')
|
|
105
|
+
.action(async (options) => {
|
|
106
|
+
const config = await readConfig();
|
|
107
|
+
const client = getClient(config);
|
|
108
|
+
const projectKey = options.projectKey ?? config.defaultProjectKey;
|
|
109
|
+
|
|
110
|
+
if (!projectKey) {
|
|
111
|
+
throw new Error('Missing project key. Pass --project-key or run start first.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const project = await ensureProject(client, projectKey, projectKey);
|
|
115
|
+
const active = await listActiveSessions(client, project.id);
|
|
116
|
+
|
|
117
|
+
console.log(`Project: ${project.project_key}`);
|
|
118
|
+
|
|
119
|
+
if (active.length === 0) {
|
|
120
|
+
console.log('No active sessions');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const session of active) {
|
|
125
|
+
console.log(`- ${session.id} | actor=${session.actor} | started=${session.started_at}`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
program
|
|
130
|
+
.command('log')
|
|
131
|
+
.description('Show session event log')
|
|
132
|
+
.option('--session-id <sessionId>', 'Session id (defaults to last session)')
|
|
133
|
+
.option('--limit <limit>', 'Number of events', '50')
|
|
134
|
+
.action(async (options) => {
|
|
135
|
+
const config = await readConfig();
|
|
136
|
+
const client = getClient(config);
|
|
137
|
+
const sessionId = options.sessionId ?? config.lastSessionId;
|
|
138
|
+
|
|
139
|
+
if (!sessionId) {
|
|
140
|
+
throw new Error('Missing session id. Pass --session-id or run start/resume first.');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const events = await getSessionEvents(client, sessionId, Number.parseInt(options.limit, 10));
|
|
144
|
+
|
|
145
|
+
if (events.length === 0) {
|
|
146
|
+
console.log(`No events for session ${sessionId}`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const event of events) {
|
|
151
|
+
console.log(`${event.created_at} | ${event.type} | ${JSON.stringify(event.payload)}`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
program
|
|
156
|
+
.command('config-path')
|
|
157
|
+
.description('Print local config path')
|
|
158
|
+
.action(() => {
|
|
159
|
+
console.log(getConfigPath());
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
async function main() {
|
|
163
|
+
try {
|
|
164
|
+
await program.parseAsync(process.argv);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
167
|
+
console.error(message);
|
|
168
|
+
process.exitCode = 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
await main();
|
package/src/config.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), '.opensession');
|
|
6
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
export async function readConfig() {
|
|
9
|
+
try {
|
|
10
|
+
const raw = await fs.readFile(CONFIG_PATH, 'utf8');
|
|
11
|
+
return JSON.parse(raw);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (error.code === 'ENOENT') {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function writeConfig(config) {
|
|
21
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
22
|
+
await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
23
|
+
return CONFIG_PATH;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getConfigPath() {
|
|
27
|
+
return CONFIG_PATH;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function mergeConfig(base, patch) {
|
|
31
|
+
return { ...base, ...patch };
|
|
32
|
+
}
|
package/src/supabase.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
|
|
3
|
+
export function getClient(config) {
|
|
4
|
+
if (!config.supabaseUrl || !config.supabaseAnonKey) {
|
|
5
|
+
throw new Error('Supabase is not configured. Run `opensession init --url <url> --anon-key <key>`.');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return createClient(config.supabaseUrl, config.supabaseAnonKey, {
|
|
9
|
+
auth: { persistSession: false }
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function ensureProject(client, projectKey, projectName) {
|
|
14
|
+
const { data: existing, error: lookupError } = await client
|
|
15
|
+
.from('projects')
|
|
16
|
+
.select('id,project_key,name')
|
|
17
|
+
.eq('project_key', projectKey)
|
|
18
|
+
.maybeSingle();
|
|
19
|
+
|
|
20
|
+
if (lookupError) {
|
|
21
|
+
throw lookupError;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (existing) {
|
|
25
|
+
return existing;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { data: created, error: insertError } = await client
|
|
29
|
+
.from('projects')
|
|
30
|
+
.insert({ project_key: projectKey, name: projectName ?? projectKey })
|
|
31
|
+
.select('id,project_key,name')
|
|
32
|
+
.single();
|
|
33
|
+
|
|
34
|
+
if (insertError) {
|
|
35
|
+
throw insertError;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return created;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function startSession(client, projectId, actor) {
|
|
42
|
+
const { data: session, error: sessionError } = await client
|
|
43
|
+
.from('sessions')
|
|
44
|
+
.insert({ project_id: projectId, actor, status: 'active', started_at: new Date().toISOString() })
|
|
45
|
+
.select('id,project_id,actor,status,started_at')
|
|
46
|
+
.single();
|
|
47
|
+
|
|
48
|
+
if (sessionError) {
|
|
49
|
+
throw sessionError;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { error: eventError } = await client
|
|
53
|
+
.from('session_events')
|
|
54
|
+
.insert({ session_id: session.id, type: 'started', payload: { actor } });
|
|
55
|
+
|
|
56
|
+
if (eventError) {
|
|
57
|
+
throw eventError;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return session;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function appendEvent(client, sessionId, type, payload = {}) {
|
|
64
|
+
const { data, error } = await client
|
|
65
|
+
.from('session_events')
|
|
66
|
+
.insert({ session_id: sessionId, type, payload })
|
|
67
|
+
.select('id,session_id,type,payload,created_at')
|
|
68
|
+
.single();
|
|
69
|
+
|
|
70
|
+
if (error) {
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function getSession(client, sessionId) {
|
|
78
|
+
const { data, error } = await client
|
|
79
|
+
.from('sessions')
|
|
80
|
+
.select('id,project_id,actor,status,started_at,ended_at')
|
|
81
|
+
.eq('id', sessionId)
|
|
82
|
+
.maybeSingle();
|
|
83
|
+
|
|
84
|
+
if (error) {
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return data;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function listActiveSessions(client, projectId) {
|
|
92
|
+
const { data, error } = await client
|
|
93
|
+
.from('sessions')
|
|
94
|
+
.select('id,project_id,actor,status,started_at,ended_at')
|
|
95
|
+
.eq('project_id', projectId)
|
|
96
|
+
.eq('status', 'active')
|
|
97
|
+
.order('started_at', { ascending: false });
|
|
98
|
+
|
|
99
|
+
if (error) {
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return data;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function getSessionEvents(client, sessionId, limit = 50) {
|
|
107
|
+
const { data, error } = await client
|
|
108
|
+
.from('session_events')
|
|
109
|
+
.select('id,session_id,type,payload,created_at')
|
|
110
|
+
.eq('session_id', sessionId)
|
|
111
|
+
.order('created_at', { ascending: true })
|
|
112
|
+
.limit(limit);
|
|
113
|
+
|
|
114
|
+
if (error) {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return data;
|
|
119
|
+
}
|