@online5880/opensession 0.1.1 → 0.1.3
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.ko.md +133 -0
- package/README.md +137 -32
- package/package.json +23 -20
- package/site/index.html +35 -0
- package/sql/schema.sql +38 -27
- package/src/automation.js +197 -0
- package/src/cli.js +1317 -284
- package/src/config.js +118 -32
- package/src/hook-server.js +194 -0
- package/src/idempotency.js +66 -0
- package/src/metrics.js +110 -0
- package/src/supabase.js +309 -129
- package/src/tui.js +159 -0
- package/src/viewer.js +708 -0
- package/test/cli-compatibility.test.js +63 -0
- package/test/config-secrets.test.js +47 -0
- package/test/idempotency.test.js +30 -0
- package/test/supabase-append-event.test.js +133 -0
package/src/cli.js
CHANGED
|
@@ -1,284 +1,1317 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { Command } from 'commander';
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { startHookServer } from './hook-server.js';
|
|
14
|
+
import {
|
|
15
|
+
appendEvent,
|
|
16
|
+
ensureProject,
|
|
17
|
+
getClient,
|
|
18
|
+
getSession,
|
|
19
|
+
getSessionEvents,
|
|
20
|
+
listSessionEvents,
|
|
21
|
+
listActiveSessions,
|
|
22
|
+
listSessions,
|
|
23
|
+
startSession,
|
|
24
|
+
validateConnection
|
|
25
|
+
} from './supabase.js';
|
|
26
|
+
import { getConfigPath, mergeConfig, readConfig, writeConfig } from './config.js';
|
|
27
|
+
import { releaseResumeOperation, reserveResumeOperation } from './idempotency.js';
|
|
28
|
+
import { computeKpis, computeWeeklyTrend, formatSignedDelta } from './metrics.js';
|
|
29
|
+
import { startViewerServer } from './viewer.js';
|
|
30
|
+
import { startTui } from './tui.js';
|
|
31
|
+
|
|
32
|
+
const program = new Command();
|
|
33
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
34
|
+
const __dirname = path.dirname(__filename);
|
|
35
|
+
const SCHEMA_SQL_PATH = path.resolve(__dirname, '../sql/schema.sql');
|
|
36
|
+
const PACKAGE_JSON_PATH = path.resolve(__dirname, '../package.json');
|
|
37
|
+
|
|
38
|
+
let packageMetadata = {
|
|
39
|
+
name: '@online5880/opensession',
|
|
40
|
+
version: '0.0.0'
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const raw = readFileSync(PACKAGE_JSON_PATH, 'utf8');
|
|
45
|
+
const parsed = JSON.parse(raw);
|
|
46
|
+
packageMetadata = {
|
|
47
|
+
name: typeof parsed.name === 'string' ? parsed.name : packageMetadata.name,
|
|
48
|
+
version: typeof parsed.version === 'string' ? parsed.version : packageMetadata.version
|
|
49
|
+
};
|
|
50
|
+
} catch {
|
|
51
|
+
// Fall back to defaults when package metadata is unavailable.
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatError(error) {
|
|
55
|
+
if (error instanceof Error) {
|
|
56
|
+
return error.message;
|
|
57
|
+
}
|
|
58
|
+
if (error && typeof error === 'object') {
|
|
59
|
+
const message = error.message;
|
|
60
|
+
const details = error.details;
|
|
61
|
+
const hint = error.hint;
|
|
62
|
+
const code = error.code;
|
|
63
|
+
const parts = [message, details, hint, code].filter(Boolean);
|
|
64
|
+
if (parts.length > 0) {
|
|
65
|
+
return parts.join(' | ');
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return JSON.stringify(error);
|
|
69
|
+
} catch {
|
|
70
|
+
return 'Unknown object error';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return String(error);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function maskSecret(value, { keepStart = 4, keepEnd = 4 } = {}) {
|
|
77
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
78
|
+
if (!text) {
|
|
79
|
+
return '(empty)';
|
|
80
|
+
}
|
|
81
|
+
if (text.length <= keepStart + keepEnd) {
|
|
82
|
+
return '*'.repeat(text.length);
|
|
83
|
+
}
|
|
84
|
+
const start = text.slice(0, keepStart);
|
|
85
|
+
const end = text.slice(-keepEnd);
|
|
86
|
+
return `${start}${'*'.repeat(text.length - keepStart - keepEnd)}${end}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readInitWizardInputs(current, options) {
|
|
90
|
+
if (input.isTTY) {
|
|
91
|
+
const rl = createInterface({ input, output });
|
|
92
|
+
const existingProjectKey = typeof current?.defaultProjectKey === 'string' ? current.defaultProjectKey.trim() : '';
|
|
93
|
+
const existingActor = typeof current?.actor === 'string' ? current.actor.trim() : '';
|
|
94
|
+
|
|
95
|
+
const url = (await rl.question('Enter Supabase URL: ')).trim();
|
|
96
|
+
const anonKey = (await rl.question('Enter Supabase ANON KEY: ')).trim();
|
|
97
|
+
|
|
98
|
+
let projectKey = typeof options.projectKey === 'string' ? options.projectKey.trim() : '';
|
|
99
|
+
if (!projectKey) {
|
|
100
|
+
const defaultProjectLabel = existingProjectKey ? ` (default ${existingProjectKey})` : '';
|
|
101
|
+
const projectAnswer = (await rl.question(`Default project key (optional)${defaultProjectLabel}: `)).trim();
|
|
102
|
+
projectKey = projectAnswer || existingProjectKey;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let actor = typeof options.actor === 'string' ? options.actor.trim() : '';
|
|
106
|
+
if (!actor) {
|
|
107
|
+
const defaultActorLabel = existingActor ? ` (default ${existingActor})` : '';
|
|
108
|
+
const actorAnswer = (await rl.question(`Default actor (optional)${defaultActorLabel}: `)).trim();
|
|
109
|
+
actor = actorAnswer || existingActor;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
rl.close();
|
|
113
|
+
return { url, anonKey, projectKey, actor };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const raw = await readFile('/dev/stdin', 'utf8');
|
|
117
|
+
const lines = raw
|
|
118
|
+
.split(/\r?\n/)
|
|
119
|
+
.map((line) => line.trim())
|
|
120
|
+
.filter((line) => line.length > 0);
|
|
121
|
+
return {
|
|
122
|
+
url: lines[0] ?? '',
|
|
123
|
+
anonKey: lines[1] ?? '',
|
|
124
|
+
projectKey: typeof options.projectKey === 'string' ? options.projectKey.trim() : '',
|
|
125
|
+
actor: typeof options.actor === 'string' ? options.actor.trim() : ''
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function inferProjectRefFromSupabaseUrl(url) {
|
|
130
|
+
try {
|
|
131
|
+
const parsed = new URL(url);
|
|
132
|
+
const host = parsed.hostname ?? '';
|
|
133
|
+
if (!host.endsWith('.supabase.co')) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const [ref] = host.split('.');
|
|
137
|
+
return ref?.trim() ? ref : null;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isSchemaMissingError(error) {
|
|
144
|
+
if (!error) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
const code = String(error.code ?? '').toUpperCase();
|
|
148
|
+
const message = String(error.message ?? '').toLowerCase();
|
|
149
|
+
const details = String(error.details ?? '').toLowerCase();
|
|
150
|
+
const hint = String(error.hint ?? '').toLowerCase();
|
|
151
|
+
const serialized = formatError(error).toLowerCase();
|
|
152
|
+
if (code === 'PGRST205') {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
const haystack = `${message} ${details} ${hint} ${serialized}`;
|
|
156
|
+
if (haystack.includes('pgrst205')) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
if (haystack.includes("could not find the table 'public.projects' in the schema cache")) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return haystack.includes('relation') && haystack.includes('projects');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function applySchemaWithManagementApi({ token, projectRef }) {
|
|
166
|
+
const sql = await readFile(SCHEMA_SQL_PATH, 'utf8');
|
|
167
|
+
const response = await fetch(`https://api.supabase.com/v1/projects/${projectRef}/database/query`, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: {
|
|
170
|
+
Authorization: `Bearer ${token}`,
|
|
171
|
+
'Content-Type': 'application/json'
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify({ query: sql })
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
const body = await response.text();
|
|
178
|
+
throw new Error(`Management API request failed (${response.status}): ${body.slice(0, 400)}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function handleSchemaBootstrapFlow(url, anonKey, validationError) {
|
|
183
|
+
const message = formatError(validationError);
|
|
184
|
+
console.log(`Connection validation: Failed (${message})`);
|
|
185
|
+
console.log('Reason: Supabase tables not found (PGRST205 detected).');
|
|
186
|
+
console.log('Choose bootstrap option:');
|
|
187
|
+
console.log(' [A] Apply schema.sql automatically via Supabase Management API');
|
|
188
|
+
console.log(' [B] Print manual instructions and re-validate');
|
|
189
|
+
|
|
190
|
+
const inferredRef = inferProjectRefFromSupabaseUrl(url);
|
|
191
|
+
if (!input.isTTY) {
|
|
192
|
+
const tokenFromEnv = process.env.SUPABASE_MANAGEMENT_TOKEN?.trim();
|
|
193
|
+
const projectRefFromEnv = process.env.SUPABASE_PROJECT_REF?.trim();
|
|
194
|
+
const projectRef = projectRefFromEnv || inferredRef;
|
|
195
|
+
|
|
196
|
+
if (tokenFromEnv && projectRef) {
|
|
197
|
+
console.log(`Running non-interactive bootstrap... projectRef=${projectRef}`);
|
|
198
|
+
try {
|
|
199
|
+
await applySchemaWithManagementApi({ token: tokenFromEnv, projectRef });
|
|
200
|
+
} catch (applyError) {
|
|
201
|
+
const applyMessage = formatError(applyError);
|
|
202
|
+
console.log(`Auto bootstrap: Failed (${applyMessage})`);
|
|
203
|
+
console.log(`schema.sql path: ${SCHEMA_SQL_PATH}`);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
await validateConnection(url, anonKey);
|
|
209
|
+
console.log('Connection re-validation: Success');
|
|
210
|
+
return true;
|
|
211
|
+
} catch (retryError) {
|
|
212
|
+
const retryMessage = formatError(retryError);
|
|
213
|
+
console.log(`Connection re-validation: Failed (${retryMessage})`);
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log('Non-interactive mode requires SUPABASE_MANAGEMENT_TOKEN/SUPABASE_PROJECT_REF for auto-bootstrap.');
|
|
219
|
+
console.log(`schema.sql path: ${SCHEMA_SQL_PATH}`);
|
|
220
|
+
if (inferredRef) {
|
|
221
|
+
console.log(
|
|
222
|
+
`one-step command: psql \"postgresql://postgres:<DB_PASSWORD>@db.${inferredRef}.supabase.co:5432/postgres\" -f \"${SCHEMA_SQL_PATH}\"`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const rl = createInterface({ input, output });
|
|
229
|
+
try {
|
|
230
|
+
const rawChoice = (await rl.question('Choice (A/B, default B): ')).trim().toUpperCase();
|
|
231
|
+
const choice = rawChoice === 'A' ? 'A' : 'B';
|
|
232
|
+
|
|
233
|
+
if (choice === 'A') {
|
|
234
|
+
const token = (await rl.question('Supabase Management API token (sbp_...): ')).trim();
|
|
235
|
+
const defaultRef = inferredRef ?? '';
|
|
236
|
+
const refPrompt = defaultRef
|
|
237
|
+
? `Project ref (default ${defaultRef}): `
|
|
238
|
+
: 'Project ref (e.g. abcdefghijklmno): ';
|
|
239
|
+
const projectRefInput = (await rl.question(refPrompt)).trim();
|
|
240
|
+
const projectRef = projectRefInput || defaultRef;
|
|
241
|
+
|
|
242
|
+
if (!token || !projectRef) {
|
|
243
|
+
throw new Error('Option A requires both Management API token and project ref.');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
console.log(`Running auto-bootstrap... projectRef=${projectRef}`);
|
|
247
|
+
await applySchemaWithManagementApi({ token, projectRef });
|
|
248
|
+
console.log('Auto bootstrap: Success');
|
|
249
|
+
} else {
|
|
250
|
+
const refLabel = inferredRef ? inferredRef : '<PROJECT_REF>';
|
|
251
|
+
console.log(`schema.sql path: ${SCHEMA_SQL_PATH}`);
|
|
252
|
+
console.log(
|
|
253
|
+
`one-step command: psql \"postgresql://postgres:<DB_PASSWORD>@db.${refLabel}.supabase.co:5432/postgres\" -f \"${SCHEMA_SQL_PATH}\"`
|
|
254
|
+
);
|
|
255
|
+
await rl.question('Press Enter after applying SQL manually to re-validate: ');
|
|
256
|
+
}
|
|
257
|
+
} finally {
|
|
258
|
+
rl.close();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
await validateConnection(url, anonKey);
|
|
263
|
+
console.log('Connection re-validation: Success');
|
|
264
|
+
return true;
|
|
265
|
+
} catch (retryError) {
|
|
266
|
+
const retryMessage = formatError(retryError);
|
|
267
|
+
console.log(`Connection re-validation: Failed (${retryMessage})`);
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseSemver(version) {
|
|
273
|
+
const normalized = String(version ?? '').trim().replace(/^v/i, '');
|
|
274
|
+
const [core] = normalized.split('-');
|
|
275
|
+
const parts = core.split('.');
|
|
276
|
+
if (parts.length < 1 || parts.length > 3) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const numbers = [0, 0, 0];
|
|
281
|
+
for (let i = 0; i < Math.min(parts.length, 3); i += 1) {
|
|
282
|
+
const value = Number.parseInt(parts[i], 10);
|
|
283
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
numbers[i] = value;
|
|
287
|
+
}
|
|
288
|
+
return numbers;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function compareSemver(left, right) {
|
|
292
|
+
const l = parseSemver(left);
|
|
293
|
+
const r = parseSemver(right);
|
|
294
|
+
if (!l || !r) {
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (let i = 0; i < 3; i += 1) {
|
|
299
|
+
if (l[i] > r[i]) {
|
|
300
|
+
return 1;
|
|
301
|
+
}
|
|
302
|
+
if (l[i] < r[i]) {
|
|
303
|
+
return -1;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function toPercent(value) {
|
|
310
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function fetchLatestVersionFromNpm(packageName) {
|
|
314
|
+
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, {
|
|
315
|
+
headers: { Accept: 'application/json' },
|
|
316
|
+
signal: AbortSignal.timeout(5000)
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
if (!response.ok) {
|
|
320
|
+
throw new Error(`npm registry request failed (${response.status})`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const data = await response.json();
|
|
324
|
+
const latest = data?.['dist-tags']?.latest;
|
|
325
|
+
if (!latest || typeof latest !== 'string') {
|
|
326
|
+
throw new Error('Could not resolve latest version from npm registry');
|
|
327
|
+
}
|
|
328
|
+
return latest;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function runNpmGlobalUpdate(packageName) {
|
|
332
|
+
await new Promise((resolve, reject) => {
|
|
333
|
+
const child = spawn('npm', ['install', '-g', `${packageName}@latest`], {
|
|
334
|
+
stdio: 'inherit'
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
child.once('error', reject);
|
|
338
|
+
child.once('exit', (code) => {
|
|
339
|
+
if (code === 0) {
|
|
340
|
+
resolve();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
reject(new Error(`npm install exited with code ${code ?? 'unknown'}`));
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function readAutomationConfigFromFile(pathValue) {
|
|
349
|
+
const raw = await readFile(pathValue, 'utf8');
|
|
350
|
+
const parsed = JSON.parse(raw);
|
|
351
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
352
|
+
throw new Error('Automation config file must be a JSON object.');
|
|
353
|
+
}
|
|
354
|
+
return parsed;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function toNumberInRange(raw, fallback, min, max) {
|
|
358
|
+
const value = Number.parseInt(String(raw ?? ''), 10);
|
|
359
|
+
if (!Number.isInteger(value)) {
|
|
360
|
+
return fallback;
|
|
361
|
+
}
|
|
362
|
+
if (value < min) {
|
|
363
|
+
return min;
|
|
364
|
+
}
|
|
365
|
+
if (value > max) {
|
|
366
|
+
return max;
|
|
367
|
+
}
|
|
368
|
+
return value;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function renderOpsDashboard({
|
|
372
|
+
projectKey,
|
|
373
|
+
sessions,
|
|
374
|
+
selectedIndex,
|
|
375
|
+
selectedSession,
|
|
376
|
+
events,
|
|
377
|
+
tailLimit,
|
|
378
|
+
showAllSessions,
|
|
379
|
+
refreshMs,
|
|
380
|
+
lastUpdatedAt,
|
|
381
|
+
lastError
|
|
382
|
+
}) {
|
|
383
|
+
const lines = [];
|
|
384
|
+
lines.push('\x1Bc');
|
|
385
|
+
lines.push(`OpenSession Ops Console | project=${projectKey}`);
|
|
386
|
+
lines.push(
|
|
387
|
+
`Shortcuts: [j/k] move [r] refresh [l] tail-limit [a] active/all [q] quit | refresh=${refreshMs}ms tail=${tailLimit} scope=${
|
|
388
|
+
showAllSessions ? 'all' : 'active'
|
|
389
|
+
}`
|
|
390
|
+
);
|
|
391
|
+
lines.push(`Updated: ${lastUpdatedAt ?? '-'}${lastError ? ` | last error: ${lastError}` : ''}`);
|
|
392
|
+
lines.push('');
|
|
393
|
+
lines.push(`Sessions (${sessions.length})`);
|
|
394
|
+
|
|
395
|
+
if (sessions.length === 0) {
|
|
396
|
+
lines.push(' (none)');
|
|
397
|
+
} else {
|
|
398
|
+
sessions.slice(0, 12).forEach((session, index) => {
|
|
399
|
+
const marker = index === selectedIndex ? '>' : ' ';
|
|
400
|
+
lines.push(
|
|
401
|
+
`${marker} ${session.id} | actor=${session.actor} | status=${session.status} | started=${session.started_at}`
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
lines.push('');
|
|
407
|
+
lines.push(`Events (${events.length})${selectedSession ? ` | session=${selectedSession.id}` : ''}`);
|
|
408
|
+
if (events.length === 0) {
|
|
409
|
+
lines.push(' (none)');
|
|
410
|
+
} else {
|
|
411
|
+
for (const event of events) {
|
|
412
|
+
lines.push(` ${event.created_at} | ${event.type} | ${JSON.stringify(event.payload)}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
output.write(`${lines.join('\n')}\n`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function runOpsConsole(options) {
|
|
420
|
+
if (!input.isTTY) {
|
|
421
|
+
throw new Error('ops requires an interactive terminal (TTY).');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const refreshMs = toNumberInRange(options.refreshMs, 5000, 1000, 60000);
|
|
425
|
+
let tailLimit = toNumberInRange(options.limit, 50, 10, 200);
|
|
426
|
+
const tailLimitOptions = [20, 50, 100, 200];
|
|
427
|
+
const config = await readConfig();
|
|
428
|
+
const client = getClient(config);
|
|
429
|
+
const projectKey = options.projectKey ?? options.project ?? config.defaultProjectKey ?? config.syncStatus?.project;
|
|
430
|
+
if (!projectKey) {
|
|
431
|
+
throw new Error('Missing project key. Pass --project-key, sync --project, or run start first.');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const project = await ensureProject(client, projectKey, projectKey);
|
|
435
|
+
let selectedIndex = 0;
|
|
436
|
+
let showAllSessions = false;
|
|
437
|
+
let sessions = [];
|
|
438
|
+
let selectedSession = null;
|
|
439
|
+
let events = [];
|
|
440
|
+
let lastUpdatedAt = null;
|
|
441
|
+
let lastError = null;
|
|
442
|
+
|
|
443
|
+
const refresh = async () => {
|
|
444
|
+
try {
|
|
445
|
+
const allSessions = await listSessions(client, project.id, 120);
|
|
446
|
+
sessions = showAllSessions ? allSessions : allSessions.filter((session) => session.status === 'active');
|
|
447
|
+
if (sessions.length === 0) {
|
|
448
|
+
selectedIndex = 0;
|
|
449
|
+
selectedSession = null;
|
|
450
|
+
events = [];
|
|
451
|
+
} else {
|
|
452
|
+
if (selectedIndex > sessions.length - 1) {
|
|
453
|
+
selectedIndex = sessions.length - 1;
|
|
454
|
+
}
|
|
455
|
+
selectedSession = sessions[selectedIndex];
|
|
456
|
+
events = await getSessionEvents(client, selectedSession.id, tailLimit, { ascending: false });
|
|
457
|
+
events.reverse();
|
|
458
|
+
}
|
|
459
|
+
lastError = null;
|
|
460
|
+
} catch (error) {
|
|
461
|
+
lastError = formatError(error);
|
|
462
|
+
}
|
|
463
|
+
lastUpdatedAt = new Date().toISOString();
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const redraw = () => {
|
|
467
|
+
renderOpsDashboard({
|
|
468
|
+
projectKey,
|
|
469
|
+
sessions,
|
|
470
|
+
selectedIndex,
|
|
471
|
+
selectedSession,
|
|
472
|
+
events,
|
|
473
|
+
tailLimit,
|
|
474
|
+
showAllSessions,
|
|
475
|
+
refreshMs,
|
|
476
|
+
lastUpdatedAt,
|
|
477
|
+
lastError
|
|
478
|
+
});
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
await refresh();
|
|
482
|
+
redraw();
|
|
483
|
+
|
|
484
|
+
let intervalHandle = null;
|
|
485
|
+
let closed = false;
|
|
486
|
+
const close = () => {
|
|
487
|
+
if (closed) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
closed = true;
|
|
491
|
+
if (intervalHandle) {
|
|
492
|
+
clearInterval(intervalHandle);
|
|
493
|
+
}
|
|
494
|
+
input.off('keypress', onKeypress);
|
|
495
|
+
if (typeof input.setRawMode === 'function') {
|
|
496
|
+
input.setRawMode(false);
|
|
497
|
+
}
|
|
498
|
+
output.write('\n');
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const onKeypress = async (_value, key) => {
|
|
502
|
+
try {
|
|
503
|
+
if (key?.ctrl && key.name === 'c') {
|
|
504
|
+
close();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (!key?.name) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (key.name === 'q') {
|
|
512
|
+
close();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if ((key.name === 'j' || key.name === 'down') && sessions.length > 0) {
|
|
517
|
+
selectedIndex = Math.min(selectedIndex + 1, sessions.length - 1);
|
|
518
|
+
await refresh();
|
|
519
|
+
redraw();
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if ((key.name === 'k' || key.name === 'up') && sessions.length > 0) {
|
|
524
|
+
selectedIndex = Math.max(selectedIndex - 1, 0);
|
|
525
|
+
await refresh();
|
|
526
|
+
redraw();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (key.name === 'r') {
|
|
531
|
+
await refresh();
|
|
532
|
+
redraw();
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (key.name === 'l') {
|
|
537
|
+
const index = tailLimitOptions.indexOf(tailLimit);
|
|
538
|
+
tailLimit = tailLimitOptions[(index + 1) % tailLimitOptions.length];
|
|
539
|
+
await refresh();
|
|
540
|
+
redraw();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (key.name === 'a') {
|
|
545
|
+
showAllSessions = !showAllSessions;
|
|
546
|
+
selectedIndex = 0;
|
|
547
|
+
await refresh();
|
|
548
|
+
redraw();
|
|
549
|
+
}
|
|
550
|
+
} catch (error) {
|
|
551
|
+
lastError = formatError(error);
|
|
552
|
+
redraw();
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
readline.emitKeypressEvents(input);
|
|
557
|
+
if (typeof input.setRawMode === 'function') {
|
|
558
|
+
input.setRawMode(true);
|
|
559
|
+
}
|
|
560
|
+
input.on('keypress', onKeypress);
|
|
561
|
+
|
|
562
|
+
intervalHandle = setInterval(async () => {
|
|
563
|
+
if (closed) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
await refresh();
|
|
567
|
+
redraw();
|
|
568
|
+
}, refreshMs);
|
|
569
|
+
|
|
570
|
+
await new Promise((resolve) => {
|
|
571
|
+
const done = () => {
|
|
572
|
+
close();
|
|
573
|
+
resolve();
|
|
574
|
+
};
|
|
575
|
+
input.once('end', done);
|
|
576
|
+
const poll = setInterval(() => {
|
|
577
|
+
if (closed) {
|
|
578
|
+
clearInterval(poll);
|
|
579
|
+
resolve();
|
|
580
|
+
}
|
|
581
|
+
}, 100);
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function parsePositiveInt(raw, fallback) {
|
|
586
|
+
const value = Number.parseInt(String(raw ?? ''), 10);
|
|
587
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
588
|
+
return fallback;
|
|
589
|
+
}
|
|
590
|
+
return value;
|
|
591
|
+
}
|
|
592
|
+
async function resolveSessionIdFromOptions(options, config, client) {
|
|
593
|
+
if (options.sessionId) {
|
|
594
|
+
return options.sessionId;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (config.lastSessionId) {
|
|
598
|
+
return config.lastSessionId;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const projectKey = options.project ?? options.projectKey ?? config.defaultProjectKey ?? config.syncStatus?.project;
|
|
602
|
+
if (!projectKey) {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
const project = await ensureProject(client, projectKey, projectKey);
|
|
606
|
+
const sessions = await listSessions(client, project.id, 1);
|
|
607
|
+
return sessions[0]?.id ?? null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function delay(ms) {
|
|
611
|
+
return new Promise((resolve) => {
|
|
612
|
+
setTimeout(resolve, ms);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
program
|
|
617
|
+
.name('opensession')
|
|
618
|
+
.description('Session continuity bridge CLI for Supabase')
|
|
619
|
+
.version(packageMetadata.version);
|
|
620
|
+
|
|
621
|
+
program
|
|
622
|
+
.command('init')
|
|
623
|
+
.alias('setup')
|
|
624
|
+
.description('Initialize CLI config with interactive prompt')
|
|
625
|
+
.option('--project-key <projectKey>', 'Default project key')
|
|
626
|
+
.option('--actor <actor>', 'Default actor/username')
|
|
627
|
+
.action(async (options) => {
|
|
628
|
+
const current = await readConfig();
|
|
629
|
+
const { url, anonKey, projectKey, actor } = await readInitWizardInputs(current, options);
|
|
630
|
+
|
|
631
|
+
if (!url || !anonKey) {
|
|
632
|
+
throw new Error('Supabase URL and ANON KEY are required.');
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const next = mergeConfig(current, {
|
|
636
|
+
supabaseUrl: url,
|
|
637
|
+
supabaseAnonKey: anonKey,
|
|
638
|
+
defaultProjectKey: projectKey || current.defaultProjectKey,
|
|
639
|
+
actor: actor || current.actor
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const configPath = await writeConfig(next);
|
|
643
|
+
console.log(`Configuration saved to: ${configPath}`);
|
|
644
|
+
console.log(`- Supabase URL: ${next.supabaseUrl}`);
|
|
645
|
+
console.log(`- Supabase ANON KEY: ${maskSecret(next.supabaseAnonKey)}`);
|
|
646
|
+
console.log(`- Default project key: ${next.defaultProjectKey ?? '(not set)'}`);
|
|
647
|
+
console.log(`- Actor: ${next.actor ?? '(not set)'}`);
|
|
648
|
+
try {
|
|
649
|
+
await validateConnection(url, anonKey);
|
|
650
|
+
console.log('Connection validation: Success');
|
|
651
|
+
} catch (error) {
|
|
652
|
+
if (isSchemaMissingError(error)) {
|
|
653
|
+
const recovered = await handleSchemaBootstrapFlow(url, anonKey, error);
|
|
654
|
+
if (!recovered) {
|
|
655
|
+
process.exitCode = 1;
|
|
656
|
+
}
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const message = formatError(error);
|
|
660
|
+
console.log(`Connection validation: Failed (${message})`);
|
|
661
|
+
process.exitCode = 1;
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
program
|
|
666
|
+
.command('login')
|
|
667
|
+
.description('Save actor identity used in session events')
|
|
668
|
+
.requiredOption('--actor <actor>', 'Actor/username')
|
|
669
|
+
.action(async (options) => {
|
|
670
|
+
const current = await readConfig();
|
|
671
|
+
const next = mergeConfig(current, { actor: options.actor });
|
|
672
|
+
await writeConfig(next);
|
|
673
|
+
console.log(`Logged in as ${options.actor}`);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
program
|
|
677
|
+
.command('start')
|
|
678
|
+
.alias('st')
|
|
679
|
+
.description('Start a new session and emit a start event')
|
|
680
|
+
.requiredOption('--project-key <projectKey>', 'Project key')
|
|
681
|
+
.option('--project-name <projectName>', 'Project display name')
|
|
682
|
+
.option('--actor <actor>', 'Actor override')
|
|
683
|
+
.action(async (options) => {
|
|
684
|
+
const config = await readConfig();
|
|
685
|
+
const client = getClient(config);
|
|
686
|
+
const actor = options.actor ?? config.actor ?? 'anonymous';
|
|
687
|
+
const operationId = randomUUID();
|
|
688
|
+
|
|
689
|
+
const project = await ensureProject(client, options.projectKey, options.projectName);
|
|
690
|
+
const session = await startSession(client, project.id, actor, { operationId });
|
|
691
|
+
|
|
692
|
+
await writeConfig(
|
|
693
|
+
mergeConfig(config, {
|
|
694
|
+
defaultProjectKey: options.projectKey,
|
|
695
|
+
lastSessionId: session.id
|
|
696
|
+
})
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
console.log(`Session started: ${session.id}`);
|
|
700
|
+
console.log(`Project: ${project.project_key}`);
|
|
701
|
+
console.log(`Actor: ${session.actor}`);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
program
|
|
705
|
+
.command('resume')
|
|
706
|
+
.alias('rs')
|
|
707
|
+
.description('Resume an existing session by emitting a resumed event')
|
|
708
|
+
.requiredOption('--session-id <sessionId>', 'Session id to resume')
|
|
709
|
+
.option('--actor <actor>', 'Actor override')
|
|
710
|
+
.option('--operation-id <operationId>', 'Stable idempotency key for resume')
|
|
711
|
+
.action(async (options) => {
|
|
712
|
+
let config = await readConfig();
|
|
713
|
+
const client = getClient(config);
|
|
714
|
+
const actor = options.actor ?? config.actor ?? 'anonymous';
|
|
715
|
+
const session = await getSession(client, options.sessionId);
|
|
716
|
+
|
|
717
|
+
if (!session) {
|
|
718
|
+
throw new Error(`Session not found: ${options.sessionId}`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const reservation = reserveResumeOperation(config, session.id, actor, options.operationId);
|
|
722
|
+
config = reservation.nextConfig;
|
|
723
|
+
await writeConfig(config);
|
|
724
|
+
|
|
725
|
+
const event = await appendEvent(client, session.id, 'resumed', { actor }, { idempotencyKey: reservation.operationId });
|
|
726
|
+
const postResumeConfig = mergeConfig(
|
|
727
|
+
releaseResumeOperation(config, session.id, actor),
|
|
728
|
+
{ lastSessionId: session.id }
|
|
729
|
+
);
|
|
730
|
+
await writeConfig(postResumeConfig);
|
|
731
|
+
|
|
732
|
+
console.log(`Session resumed: ${session.id}`);
|
|
733
|
+
console.log(`Event: ${event.id}`);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
program
|
|
737
|
+
.command('approve')
|
|
738
|
+
.description('Approve a session by emitting an approved event')
|
|
739
|
+
.option('--session-id <sessionId>', 'Session id (defaults to last known session or latest by project)')
|
|
740
|
+
.option('--project-key <projectKey>', 'Project key used when selecting latest session')
|
|
741
|
+
.option('--project <projectKey>', 'Alias of --project-key')
|
|
742
|
+
.option('--actor <actor>', 'Actor override')
|
|
743
|
+
.option('--note <note>', 'Approval note')
|
|
744
|
+
.option('--idempotency-key <idempotencyKey>', 'Stable idempotency key for approval event')
|
|
745
|
+
.action(async (options) => {
|
|
746
|
+
const config = await readConfig();
|
|
747
|
+
const client = getClient(config);
|
|
748
|
+
const sessionId = await resolveSessionIdFromOptions(options, config, client);
|
|
749
|
+
|
|
750
|
+
if (!sessionId) {
|
|
751
|
+
throw new Error('Missing session id. Pass --session-id, --project-key, or run start/resume first.');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const session = await getSession(client, sessionId);
|
|
755
|
+
if (!session) {
|
|
756
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const actor = options.actor ?? config.actor ?? 'operator';
|
|
760
|
+
const note = typeof options.note === 'string' && options.note.trim().length > 0
|
|
761
|
+
? options.note.trim()
|
|
762
|
+
: 'Approved from CLI command.';
|
|
763
|
+
const idempotencyKey = options.idempotencyKey ?? `approve-${session.id}-${Date.now()}`;
|
|
764
|
+
const event = await appendEvent(
|
|
765
|
+
client,
|
|
766
|
+
session.id,
|
|
767
|
+
'approved',
|
|
768
|
+
{
|
|
769
|
+
actor,
|
|
770
|
+
note,
|
|
771
|
+
source: 'cli-approve-command'
|
|
772
|
+
},
|
|
773
|
+
{ idempotencyKey }
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
if (session.id !== config.lastSessionId) {
|
|
777
|
+
await writeConfig(mergeConfig(config, { lastSessionId: session.id }));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
console.log(`Session approved: ${session.id}`);
|
|
781
|
+
console.log(`Event: ${event.id}`);
|
|
782
|
+
console.log(`Actor: ${actor}`);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
program
|
|
786
|
+
.command('list')
|
|
787
|
+
.alias('sessions')
|
|
788
|
+
.description('List recent sessions for a project')
|
|
789
|
+
.option('--project-key <projectKey>', 'Project key (defaults to configured project key)')
|
|
790
|
+
.option('--project <projectKey>', 'Alias of --project-key')
|
|
791
|
+
.option('--limit <limit>', 'Number of sessions', '20')
|
|
792
|
+
.action(async (options) => {
|
|
793
|
+
const config = await readConfig();
|
|
794
|
+
const client = getClient(config);
|
|
795
|
+
const projectKey = options.project ?? options.projectKey ?? config.defaultProjectKey ?? config.syncStatus?.project;
|
|
796
|
+
if (!projectKey) {
|
|
797
|
+
throw new Error('Missing project key. Pass --project-key, sync --project, or run start first.');
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const limit = parsePositiveInt(options.limit, 20);
|
|
801
|
+
const project = await ensureProject(client, projectKey, projectKey);
|
|
802
|
+
const sessions = await listSessions(client, project.id, limit);
|
|
803
|
+
|
|
804
|
+
if (sessions.length === 0) {
|
|
805
|
+
console.log(`No sessions for project ${project.project_key}`);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
for (const session of sessions) {
|
|
810
|
+
console.log(
|
|
811
|
+
`${session.id} | actor=${session.actor} | status=${session.status} | started=${session.started_at} | ended=${session.ended_at ?? '-'}`
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
program
|
|
817
|
+
.command('view')
|
|
818
|
+
.alias('inspect')
|
|
819
|
+
.description('Inspect a session with recent events')
|
|
820
|
+
.option('--session-id <sessionId>', 'Session id (defaults to latest known session)')
|
|
821
|
+
.option('--project-key <projectKey>', 'Project key used when selecting latest session')
|
|
822
|
+
.option('--project <projectKey>', 'Alias of --project-key')
|
|
823
|
+
.option('--tail <tail>', 'Number of recent events to show', '20')
|
|
824
|
+
.action(async (options) => {
|
|
825
|
+
const config = await readConfig();
|
|
826
|
+
const client = getClient(config);
|
|
827
|
+
let sessionId = options.sessionId ?? config.lastSessionId ?? null;
|
|
828
|
+
|
|
829
|
+
if (!sessionId) {
|
|
830
|
+
const projectKey = options.project ?? options.projectKey ?? config.defaultProjectKey ?? config.syncStatus?.project;
|
|
831
|
+
if (!projectKey) {
|
|
832
|
+
throw new Error('Missing session id. Pass --session-id, --project-key, or run start first.');
|
|
833
|
+
}
|
|
834
|
+
const project = await ensureProject(client, projectKey, projectKey);
|
|
835
|
+
const sessions = await listSessions(client, project.id, 1);
|
|
836
|
+
sessionId = sessions[0]?.id ?? null;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (!sessionId) {
|
|
840
|
+
throw new Error('No sessions found to inspect.');
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const session = await getSession(client, sessionId);
|
|
844
|
+
if (!session) {
|
|
845
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const tail = parsePositiveInt(options.tail, 20);
|
|
849
|
+
const events = await getSessionEvents(client, session.id, tail, { ascending: false });
|
|
850
|
+
const ordered = [...events].reverse();
|
|
851
|
+
await writeConfig(mergeConfig(config, { lastSessionId: session.id }));
|
|
852
|
+
|
|
853
|
+
console.log(`Session: ${session.id}`);
|
|
854
|
+
console.log(`Project ID: ${session.project_id}`);
|
|
855
|
+
console.log(`Actor: ${session.actor}`);
|
|
856
|
+
console.log(`Status: ${session.status}`);
|
|
857
|
+
console.log(`Started: ${session.started_at}`);
|
|
858
|
+
console.log(`Ended: ${session.ended_at ?? '-'}`);
|
|
859
|
+
console.log(`Events: ${ordered.length} (tail=${tail})`);
|
|
860
|
+
|
|
861
|
+
for (const event of ordered) {
|
|
862
|
+
console.log(`${event.created_at} | ${event.type} | ${JSON.stringify(event.payload)}`);
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
program
|
|
867
|
+
.command('tail')
|
|
868
|
+
.alias('follow')
|
|
869
|
+
.description('Poll and print new events from a session')
|
|
870
|
+
.option('--session-id <sessionId>', 'Session id (defaults to latest known session)')
|
|
871
|
+
.option('--project-key <projectKey>', 'Project key used when selecting latest session')
|
|
872
|
+
.option('--project <projectKey>', 'Alias of --project-key')
|
|
873
|
+
.option('--limit <limit>', 'Number of recent events to request per poll', '20')
|
|
874
|
+
.option('--interval <interval>', 'Polling interval in seconds', '2')
|
|
875
|
+
.option('--iterations <iterations>', 'Number of polling rounds (0 = infinite)', '0')
|
|
876
|
+
.action(async (options) => {
|
|
877
|
+
const config = await readConfig();
|
|
878
|
+
const client = getClient(config);
|
|
879
|
+
let sessionId = options.sessionId ?? config.lastSessionId ?? null;
|
|
880
|
+
|
|
881
|
+
if (!sessionId) {
|
|
882
|
+
const projectKey = options.project ?? options.projectKey ?? config.defaultProjectKey ?? config.syncStatus?.project;
|
|
883
|
+
if (!projectKey) {
|
|
884
|
+
throw new Error('Missing session id. Pass --session-id, --project-key, or run start first.');
|
|
885
|
+
}
|
|
886
|
+
const project = await ensureProject(client, projectKey, projectKey);
|
|
887
|
+
const sessions = await listSessions(client, project.id, 1);
|
|
888
|
+
sessionId = sessions[0]?.id ?? null;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (!sessionId) {
|
|
892
|
+
throw new Error('No sessions found to tail.');
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const session = await getSession(client, sessionId);
|
|
896
|
+
if (!session) {
|
|
897
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const limit = parsePositiveInt(options.limit, 20);
|
|
901
|
+
const intervalSec = parsePositiveInt(options.interval, 2);
|
|
902
|
+
const iterations = Math.max(0, Number.parseInt(String(options.iterations ?? '0'), 10) || 0);
|
|
903
|
+
|
|
904
|
+
await writeConfig(mergeConfig(config, { lastSessionId: session.id }));
|
|
905
|
+
console.log(`Tailing session: ${session.id}`);
|
|
906
|
+
console.log(`Polling: interval=${intervalSec}s limit=${limit} iterations=${iterations === 0 ? 'infinite' : iterations}`);
|
|
907
|
+
|
|
908
|
+
const seenEventIds = new Set();
|
|
909
|
+
let remaining = iterations;
|
|
910
|
+
while (iterations === 0 || remaining > 0) {
|
|
911
|
+
const events = await getSessionEvents(client, session.id, limit, { ascending: false });
|
|
912
|
+
const ordered = [...events].reverse();
|
|
913
|
+
for (const event of ordered) {
|
|
914
|
+
if (seenEventIds.has(event.id)) {
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
seenEventIds.add(event.id);
|
|
918
|
+
console.log(`${event.created_at} | ${event.type} | ${JSON.stringify(event.payload)}`);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (iterations !== 0) {
|
|
922
|
+
remaining -= 1;
|
|
923
|
+
if (remaining <= 0) {
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
await delay(intervalSec * 1000);
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
program
|
|
933
|
+
.command('status')
|
|
934
|
+
.alias('ps')
|
|
935
|
+
.description('Show active sessions and latest sync status')
|
|
936
|
+
.option('--project-key <projectKey>', 'Project key (defaults to configured project key)')
|
|
937
|
+
.option('--project <projectKey>', 'Alias of --project-key')
|
|
938
|
+
.action(async (options) => {
|
|
939
|
+
const currentVersion = packageMetadata.version;
|
|
940
|
+
let latestVersion = '-';
|
|
941
|
+
let versionState = 'unknown';
|
|
942
|
+
|
|
943
|
+
try {
|
|
944
|
+
const latest = await fetchLatestVersionFromNpm(packageMetadata.name);
|
|
945
|
+
latestVersion = latest;
|
|
946
|
+
const compare = compareSemver(currentVersion, latest);
|
|
947
|
+
if (compare >= 0) {
|
|
948
|
+
versionState = 'up-to-date';
|
|
949
|
+
} else {
|
|
950
|
+
versionState = 'update-available';
|
|
951
|
+
}
|
|
952
|
+
} catch (error) {
|
|
953
|
+
versionState = `check-failed (${formatError(error)})`;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const config = await readConfig();
|
|
957
|
+
const projectKey = options.project ?? options.projectKey ?? config.defaultProjectKey ?? config.syncStatus?.project;
|
|
958
|
+
const syncStatus = config.syncStatus ?? {};
|
|
959
|
+
|
|
960
|
+
console.log(`CLI version: ${currentVersion}`);
|
|
961
|
+
console.log(`Latest version: ${latestVersion}`);
|
|
962
|
+
console.log(`Version status: ${versionState}`);
|
|
963
|
+
|
|
964
|
+
if (!projectKey) {
|
|
965
|
+
throw new Error('Missing project key. Pass --project-key, sync --project, or run start first.');
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
console.log(`Project: ${projectKey}`);
|
|
969
|
+
console.log(`Last sync: ${syncStatus.lastSyncAt ?? '-'}`);
|
|
970
|
+
console.log(`Sync project: ${syncStatus.project ?? '-'}`);
|
|
971
|
+
console.log(`Pending events: ${syncStatus.pendingEvents ?? 0}`);
|
|
972
|
+
console.log(`Recent sync error: ${syncStatus.lastError ?? '-'}`);
|
|
973
|
+
|
|
974
|
+
let client;
|
|
975
|
+
try {
|
|
976
|
+
client = getClient(config);
|
|
977
|
+
} catch (error) {
|
|
978
|
+
const message = formatError(error);
|
|
979
|
+
console.log(`원격 상태 확인 불가: ${message}`);
|
|
980
|
+
process.exitCode = 1;
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const project = await ensureProject(client, projectKey, projectKey);
|
|
984
|
+
const active = await listActiveSessions(client, project.id);
|
|
985
|
+
|
|
986
|
+
if (active.length === 0) {
|
|
987
|
+
console.log('No active sessions');
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
for (const session of active) {
|
|
992
|
+
console.log(`- ${session.id} | actor=${session.actor} | started=${session.started_at}`);
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
program
|
|
997
|
+
.command('self-update')
|
|
998
|
+
.description('Check for updates and optionally install the latest global version')
|
|
999
|
+
.option('--check', 'Check only, do not install')
|
|
1000
|
+
.action(async (options) => {
|
|
1001
|
+
const packageName = packageMetadata.name;
|
|
1002
|
+
const currentVersion = packageMetadata.version;
|
|
1003
|
+
const latestVersion = await fetchLatestVersionFromNpm(packageName);
|
|
1004
|
+
const compare = compareSemver(currentVersion, latestVersion);
|
|
1005
|
+
|
|
1006
|
+
console.log(`Package: ${packageName}`);
|
|
1007
|
+
console.log(`Current version: ${currentVersion}`);
|
|
1008
|
+
console.log(`Latest version: ${latestVersion}`);
|
|
1009
|
+
|
|
1010
|
+
if (compare >= 0) {
|
|
1011
|
+
console.log('Already up to date.');
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (options.check) {
|
|
1016
|
+
console.log('Update available. Run `opensession self-update` to install globally.');
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
console.log(`Installing ${packageName}@latest globally...`);
|
|
1021
|
+
await runNpmGlobalUpdate(packageName);
|
|
1022
|
+
console.log('Self-update complete.');
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
program
|
|
1026
|
+
.command('viewer')
|
|
1027
|
+
.alias('vw')
|
|
1028
|
+
.description('Run read-only web viewer for projects/sessions/events')
|
|
1029
|
+
.option('--host <host>', 'Host to bind', '127.0.0.1')
|
|
1030
|
+
.option('--port <port>', 'Port to bind', '8787')
|
|
1031
|
+
.action(async (options) => {
|
|
1032
|
+
const host = String(options.host ?? '127.0.0.1');
|
|
1033
|
+
const port = Number.parseInt(String(options.port ?? '8787'), 10);
|
|
1034
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
1035
|
+
throw new Error('Invalid port. Use a number between 1 and 65535.');
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const { server, url } = await startViewerServer({ host, port });
|
|
1039
|
+
console.log(`Read-only viewer running at ${url}`);
|
|
1040
|
+
console.log('Press Ctrl+C to stop.');
|
|
1041
|
+
|
|
1042
|
+
process.once('SIGINT', () => {
|
|
1043
|
+
server.close(() => {
|
|
1044
|
+
process.exit(0);
|
|
1045
|
+
});
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
program
|
|
1050
|
+
.command('webhook-server')
|
|
1051
|
+
.description('Run inbound webhook ingestion server with optional automation forwarding')
|
|
1052
|
+
.option('--host <host>', 'Host to bind', '127.0.0.1')
|
|
1053
|
+
.option('--port <port>', 'Port to bind', '8788')
|
|
1054
|
+
.option('--project-key <projectKey>', 'Default project key when missing from payload')
|
|
1055
|
+
.option('--actor <actor>', 'Default actor for auto-created sessions')
|
|
1056
|
+
.option('--session-id <sessionId>', 'Force all webhooks into a specific session')
|
|
1057
|
+
.option('--secret <secret>', 'Shared secret expected in x-opensession-secret header')
|
|
1058
|
+
.option('--automation-file <path>', 'JSON file with automation rules/webhooks')
|
|
1059
|
+
.option('--no-auto-start-session', 'Disable automatic session creation when no active session exists')
|
|
1060
|
+
.action(async (options) => {
|
|
1061
|
+
const host = String(options.host ?? '127.0.0.1');
|
|
1062
|
+
const port = Number.parseInt(String(options.port ?? '8788'), 10);
|
|
1063
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
1064
|
+
throw new Error('Invalid port. Use a number between 1 and 65535.');
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const config = await readConfig();
|
|
1068
|
+
const automationFromFile = options.automationFile
|
|
1069
|
+
? await readAutomationConfigFromFile(options.automationFile)
|
|
1070
|
+
: {};
|
|
1071
|
+
const automationConfig = {
|
|
1072
|
+
...(config.automation ?? {}),
|
|
1073
|
+
...automationFromFile
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
const client = getClient(config);
|
|
1077
|
+
const projectKey = options.projectKey ?? config.defaultProjectKey ?? null;
|
|
1078
|
+
const actor = options.actor ?? config.actor ?? 'integration-bot';
|
|
1079
|
+
const fixedSessionId = options.sessionId ?? null;
|
|
1080
|
+
const autoStartSession = options.autoStartSession !== false;
|
|
1081
|
+
|
|
1082
|
+
const { server, url } = await startHookServer({
|
|
1083
|
+
host,
|
|
1084
|
+
port,
|
|
1085
|
+
secret: options.secret ?? process.env.OPENSESSION_WEBHOOK_SECRET ?? null,
|
|
1086
|
+
projectKey,
|
|
1087
|
+
actor,
|
|
1088
|
+
autoStartSession,
|
|
1089
|
+
fixedSessionId,
|
|
1090
|
+
client,
|
|
1091
|
+
automationConfig
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
console.log(`Webhook server listening at ${url}`);
|
|
1095
|
+
console.log('POST events to /webhooks/event');
|
|
1096
|
+
console.log('Health check: GET /health');
|
|
1097
|
+
console.log('Press Ctrl+C to stop.');
|
|
1098
|
+
|
|
1099
|
+
process.once('SIGINT', () => {
|
|
1100
|
+
server.close(() => {
|
|
1101
|
+
process.exit(0);
|
|
1102
|
+
});
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
program
|
|
1107
|
+
.command('sync')
|
|
1108
|
+
.alias('sy')
|
|
1109
|
+
.description('Sync local/remote session state for a project')
|
|
1110
|
+
.option('--project <projectKey>', 'Project key')
|
|
1111
|
+
.option('--project-key <projectKey>', 'Alias of --project')
|
|
1112
|
+
.action(async (options) => {
|
|
1113
|
+
const config = await readConfig();
|
|
1114
|
+
const now = new Date().toISOString();
|
|
1115
|
+
const projectKey = options.project ?? options.projectKey;
|
|
1116
|
+
|
|
1117
|
+
if (!projectKey) {
|
|
1118
|
+
throw new Error('Missing project key. Pass --project or --project-key.');
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
try {
|
|
1122
|
+
const client = getClient(config);
|
|
1123
|
+
const project = await ensureProject(client, projectKey, projectKey);
|
|
1124
|
+
const active = await listActiveSessions(client, project.id);
|
|
1125
|
+
const next = mergeConfig(config, {
|
|
1126
|
+
defaultProjectKey: projectKey,
|
|
1127
|
+
syncStatus: {
|
|
1128
|
+
lastSyncAt: now,
|
|
1129
|
+
project: projectKey,
|
|
1130
|
+
pendingEvents: 0,
|
|
1131
|
+
lastError: null
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
await writeConfig(next);
|
|
1135
|
+
console.log(`Sync complete: project=${projectKey}`);
|
|
1136
|
+
console.log(`Active sessions: ${active.length}`);
|
|
1137
|
+
console.log(`Pending events: 0`);
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
const message = formatError(error);
|
|
1140
|
+
const next = mergeConfig(config, {
|
|
1141
|
+
syncStatus: {
|
|
1142
|
+
lastSyncAt: now,
|
|
1143
|
+
project: projectKey,
|
|
1144
|
+
pendingEvents: 0,
|
|
1145
|
+
lastError: message
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
await writeConfig(next);
|
|
1149
|
+
console.log(`Sync failed: ${message}`);
|
|
1150
|
+
process.exitCode = 1;
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
program
|
|
1155
|
+
.command('log')
|
|
1156
|
+
.alias('lg')
|
|
1157
|
+
.alias('logs')
|
|
1158
|
+
.description('Show session event log')
|
|
1159
|
+
.option('--session-id <sessionId>', 'Session id (defaults to last session)')
|
|
1160
|
+
.option('--limit <limit>', 'Number of events', '50')
|
|
1161
|
+
.action(async (options) => {
|
|
1162
|
+
const config = await readConfig();
|
|
1163
|
+
const client = getClient(config);
|
|
1164
|
+
const sessionId = options.sessionId ?? config.lastSessionId;
|
|
1165
|
+
|
|
1166
|
+
if (!sessionId) {
|
|
1167
|
+
throw new Error('Missing session id. Pass --session-id or run start/resume first.');
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const events = await getSessionEvents(client, sessionId, Number.parseInt(options.limit, 10));
|
|
1171
|
+
|
|
1172
|
+
if (events.length === 0) {
|
|
1173
|
+
console.log(`No events for session ${sessionId}`);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
for (const event of events) {
|
|
1178
|
+
console.log(`${event.created_at} | ${event.type} | ${JSON.stringify(event.payload)}`);
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
program
|
|
1183
|
+
.command('report')
|
|
1184
|
+
.description('Generate KPI and weekly trend report for a project')
|
|
1185
|
+
.option('--project-key <projectKey>', 'Project key (defaults to configured project key)')
|
|
1186
|
+
.option('--project <projectKey>', 'Alias of --project-key')
|
|
1187
|
+
.option('--days <days>', 'Rolling window size in days', '28')
|
|
1188
|
+
.option('--weeks <weeks>', 'Weekly buckets to show', '6')
|
|
1189
|
+
.option('--json', 'Emit report as JSON')
|
|
1190
|
+
.action(async (options) => {
|
|
1191
|
+
const config = await readConfig();
|
|
1192
|
+
const client = getClient(config);
|
|
1193
|
+
const projectKey = options.project ?? options.projectKey ?? config.defaultProjectKey ?? config.syncStatus?.project;
|
|
1194
|
+
|
|
1195
|
+
if (!projectKey) {
|
|
1196
|
+
throw new Error('Missing project key. Pass --project-key, sync --project, or run start first.');
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const days = Math.max(1, Math.min(180, Number.parseInt(String(options.days ?? '28'), 10) || 28));
|
|
1200
|
+
const weeks = Math.max(1, Math.min(26, Number.parseInt(String(options.weeks ?? '6'), 10) || 6));
|
|
1201
|
+
const now = new Date();
|
|
1202
|
+
const windowStart = new Date(now);
|
|
1203
|
+
windowStart.setUTCDate(windowStart.getUTCDate() - days);
|
|
1204
|
+
const previousWindowStart = new Date(windowStart);
|
|
1205
|
+
previousWindowStart.setUTCDate(previousWindowStart.getUTCDate() - days);
|
|
1206
|
+
|
|
1207
|
+
const project = await ensureProject(client, projectKey, projectKey);
|
|
1208
|
+
const sessions = await listSessions(client, project.id, 1000);
|
|
1209
|
+
const allSessionIds = sessions.map((session) => session.id);
|
|
1210
|
+
|
|
1211
|
+
const windowSessions = sessions.filter((session) => {
|
|
1212
|
+
const time = Date.parse(session.started_at);
|
|
1213
|
+
return Number.isFinite(time) && time >= windowStart.getTime() && time < now.getTime();
|
|
1214
|
+
});
|
|
1215
|
+
const previousWindowSessions = sessions.filter((session) => {
|
|
1216
|
+
const time = Date.parse(session.started_at);
|
|
1217
|
+
return Number.isFinite(time) && time >= previousWindowStart.getTime() && time < windowStart.getTime();
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
const [windowEvents, previousWindowEvents] = await Promise.all([
|
|
1221
|
+
listSessionEvents(client, allSessionIds, {
|
|
1222
|
+
since: windowStart.toISOString(),
|
|
1223
|
+
until: now.toISOString(),
|
|
1224
|
+
ascending: true
|
|
1225
|
+
}),
|
|
1226
|
+
listSessionEvents(client, allSessionIds, {
|
|
1227
|
+
since: previousWindowStart.toISOString(),
|
|
1228
|
+
until: windowStart.toISOString(),
|
|
1229
|
+
ascending: true
|
|
1230
|
+
})
|
|
1231
|
+
]);
|
|
1232
|
+
|
|
1233
|
+
const kpis = computeKpis(windowSessions, windowEvents);
|
|
1234
|
+
const previousKpis = computeKpis(previousWindowSessions, previousWindowEvents);
|
|
1235
|
+
const trend = computeWeeklyTrend(windowSessions, windowEvents, weeks, now);
|
|
1236
|
+
|
|
1237
|
+
const payload = {
|
|
1238
|
+
generatedAt: now.toISOString(),
|
|
1239
|
+
project: {
|
|
1240
|
+
key: project.project_key,
|
|
1241
|
+
id: project.id
|
|
1242
|
+
},
|
|
1243
|
+
window: {
|
|
1244
|
+
days,
|
|
1245
|
+
since: windowStart.toISOString(),
|
|
1246
|
+
until: now.toISOString()
|
|
1247
|
+
},
|
|
1248
|
+
kpis,
|
|
1249
|
+
deltas: {
|
|
1250
|
+
sessions: formatSignedDelta(kpis.totalSessions, previousKpis.totalSessions),
|
|
1251
|
+
activeSessions: formatSignedDelta(kpis.activeSessions, previousKpis.activeSessions),
|
|
1252
|
+
uniqueActors: formatSignedDelta(kpis.uniqueActors, previousKpis.uniqueActors),
|
|
1253
|
+
events: formatSignedDelta(kpis.totalEvents, previousKpis.totalEvents)
|
|
1254
|
+
},
|
|
1255
|
+
trend
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
if (options.json) {
|
|
1259
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
console.log(`Project KPI Report: ${project.project_key}`);
|
|
1264
|
+
console.log(`Window: ${windowStart.toISOString()} -> ${now.toISOString()} (${days}d)`);
|
|
1265
|
+
console.log('');
|
|
1266
|
+
console.log(`Sessions ${kpis.totalSessions} (${payload.deltas.sessions} vs prev ${days}d)`);
|
|
1267
|
+
console.log(`Active ${kpis.activeSessions} (${payload.deltas.activeSessions})`);
|
|
1268
|
+
console.log(`Unique actors ${kpis.uniqueActors} (${payload.deltas.uniqueActors})`);
|
|
1269
|
+
console.log(`Events ${kpis.totalEvents} (${payload.deltas.events})`);
|
|
1270
|
+
console.log(`Events/session ${kpis.eventsPerSession.toFixed(2)}`);
|
|
1271
|
+
console.log(`Resume rate ${toPercent(kpis.resumeRate)}`);
|
|
1272
|
+
console.log('');
|
|
1273
|
+
console.log('Weekly trend (week_start | sessions | actors | events)');
|
|
1274
|
+
for (const bucket of trend) {
|
|
1275
|
+
console.log(`${bucket.weekStart} | ${bucket.sessions} | ${bucket.uniqueActors} | ${bucket.events}`);
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
program
|
|
1280
|
+
.command('tui')
|
|
1281
|
+
.description('Run interactive Terminal UI (TUI) dashboard')
|
|
1282
|
+
.option('--project-key <projectKey>', 'Project key')
|
|
1283
|
+
.action(async (options) => {
|
|
1284
|
+
const config = await readConfig();
|
|
1285
|
+
const client = getClient(config);
|
|
1286
|
+
await startTui(client, options);
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
program
|
|
1290
|
+
.command('ops')
|
|
1291
|
+
.description('Run keyboard-driven ops console (TUI) for session/event monitoring')
|
|
1292
|
+
.option('--project-key <projectKey>', 'Project key (defaults to configured project key)')
|
|
1293
|
+
.option('--project <projectKey>', 'Alias of --project-key')
|
|
1294
|
+
.option('--refresh-ms <refreshMs>', 'Auto refresh interval in ms', '5000')
|
|
1295
|
+
.option('--limit <limit>', 'Tail event limit (10-200)', '50')
|
|
1296
|
+
.action(async (options) => {
|
|
1297
|
+
await runOpsConsole(options);
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
program
|
|
1301
|
+
.command('config-path')
|
|
1302
|
+
.description('Print local config path')
|
|
1303
|
+
.action(() => {
|
|
1304
|
+
console.log(getConfigPath());
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
async function main() {
|
|
1308
|
+
try {
|
|
1309
|
+
await program.parseAsync(process.argv);
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
const message = formatError(error);
|
|
1312
|
+
console.error(message);
|
|
1313
|
+
process.exitCode = 1;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
await main();
|