@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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
const CLI_PATH = path.resolve(__dirname, '../src/cli.js');
|
|
10
|
+
|
|
11
|
+
function runCli(args) {
|
|
12
|
+
return spawnSync(process.execPath, [CLI_PATH, ...args], {
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
env: process.env
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('start command help keeps required compatibility flags', () => {
|
|
19
|
+
const result = runCli(['start', '--help']);
|
|
20
|
+
assert.equal(result.status, 0);
|
|
21
|
+
assert.match(result.stdout, /--project-key <projectKey>/);
|
|
22
|
+
assert.match(result.stdout, /--project-name <projectName>/);
|
|
23
|
+
assert.match(result.stdout, /--actor <actor>/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('resume command help keeps required compatibility flags', () => {
|
|
27
|
+
const result = runCli(['resume', '--help']);
|
|
28
|
+
assert.equal(result.status, 0);
|
|
29
|
+
assert.match(result.stdout, /--session-id <sessionId>/);
|
|
30
|
+
assert.match(result.stdout, /--actor <actor>/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('approve command help keeps required compatibility flags', () => {
|
|
34
|
+
const result = runCli(['approve', '--help']);
|
|
35
|
+
assert.equal(result.status, 0);
|
|
36
|
+
assert.match(result.stdout, /--session-id <sessionId>/);
|
|
37
|
+
assert.match(result.stdout, /--project-key <projectKey>/);
|
|
38
|
+
assert.match(result.stdout, /--project <projectKey>/);
|
|
39
|
+
assert.match(result.stdout, /--actor <actor>/);
|
|
40
|
+
assert.match(result.stdout, /--note <note>/);
|
|
41
|
+
assert.match(result.stdout, /--idempotency-key <idempotencyKey>/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('status command help keeps both project-key and project alias', () => {
|
|
45
|
+
const result = runCli(['status', '--help']);
|
|
46
|
+
assert.equal(result.status, 0);
|
|
47
|
+
assert.match(result.stdout, /--project-key <projectKey>/);
|
|
48
|
+
assert.match(result.stdout, /--project <projectKey>/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('log command help keeps session and limit flags', () => {
|
|
52
|
+
const result = runCli(['log', '--help']);
|
|
53
|
+
assert.equal(result.status, 0);
|
|
54
|
+
assert.match(result.stdout, /--session-id <sessionId>/);
|
|
55
|
+
assert.match(result.stdout, /--limit <limit>/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('logs alias resolves to log command help', () => {
|
|
59
|
+
const result = runCli(['logs', '--help']);
|
|
60
|
+
assert.equal(result.status, 0);
|
|
61
|
+
assert.match(result.stdout, /Show session event log/);
|
|
62
|
+
assert.match(result.stdout, /--session-id <sessionId>/);
|
|
63
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const fixtureRoot = path.join(os.tmpdir(), `opensession-config-test-${process.pid}-${Date.now()}`);
|
|
8
|
+
const fixtureHome = path.join(fixtureRoot, 'home');
|
|
9
|
+
const configFile = path.join(fixtureHome, '.opensession', 'config.json');
|
|
10
|
+
|
|
11
|
+
test('writeConfig persists encrypted secret and readConfig decrypts it', async () => {
|
|
12
|
+
await fs.mkdir(fixtureHome, { recursive: true });
|
|
13
|
+
const prevHome = process.env.HOME;
|
|
14
|
+
const prevUserProfile = process.env.USERPROFILE;
|
|
15
|
+
process.env.HOME = fixtureHome;
|
|
16
|
+
process.env.USERPROFILE = fixtureHome;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const configModule = await import(`../src/config.js?fixture=${Date.now()}`);
|
|
20
|
+
|
|
21
|
+
await configModule.writeConfig({
|
|
22
|
+
supabaseUrl: 'https://example.supabase.co',
|
|
23
|
+
supabaseAnonKey: 'sb_secret_plaintext_for_test',
|
|
24
|
+
actor: 'tester'
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const raw = await fs.readFile(configFile, 'utf8');
|
|
28
|
+
assert.doesNotMatch(raw, /sb_secret_plaintext_for_test/);
|
|
29
|
+
assert.match(raw, /"supabaseAnonKeyEnc"\s*:\s*"enc:v1:/);
|
|
30
|
+
|
|
31
|
+
const loaded = await configModule.readConfig();
|
|
32
|
+
assert.equal(loaded.supabaseAnonKey, 'sb_secret_plaintext_for_test');
|
|
33
|
+
assert.equal(loaded.supabaseUrl, 'https://example.supabase.co');
|
|
34
|
+
assert.equal(loaded.actor, 'tester');
|
|
35
|
+
} finally {
|
|
36
|
+
if (prevHome === undefined) {
|
|
37
|
+
delete process.env.HOME;
|
|
38
|
+
} else {
|
|
39
|
+
process.env.HOME = prevHome;
|
|
40
|
+
}
|
|
41
|
+
if (prevUserProfile === undefined) {
|
|
42
|
+
delete process.env.USERPROFILE;
|
|
43
|
+
} else {
|
|
44
|
+
process.env.USERPROFILE = prevUserProfile;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { releaseResumeOperation, reserveResumeOperation } from '../src/idempotency.js';
|
|
4
|
+
|
|
5
|
+
test('reserveResumeOperation persists generated id for retry and restart safety', () => {
|
|
6
|
+
const initial = {};
|
|
7
|
+
const first = reserveResumeOperation(initial, 'session-1', 'alice');
|
|
8
|
+
assert.equal(typeof first.operationId, 'string');
|
|
9
|
+
assert.ok(first.operationId.length > 0);
|
|
10
|
+
assert.equal(first.nextConfig.pendingResumeOperations['session-1:alice'], first.operationId);
|
|
11
|
+
|
|
12
|
+
const afterRestart = reserveResumeOperation(first.nextConfig, 'session-1', 'alice');
|
|
13
|
+
assert.equal(afterRestart.operationId, first.operationId);
|
|
14
|
+
assert.equal(afterRestart.nextConfig, first.nextConfig);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('releaseResumeOperation clears pending key and next reservation gets new id', () => {
|
|
18
|
+
const reserved = reserveResumeOperation({}, 'session-2', 'bob');
|
|
19
|
+
const cleared = releaseResumeOperation(reserved.nextConfig, 'session-2', 'bob');
|
|
20
|
+
assert.equal(cleared.pendingResumeOperations['session-2:bob'], undefined);
|
|
21
|
+
|
|
22
|
+
const next = reserveResumeOperation(cleared, 'session-2', 'bob');
|
|
23
|
+
assert.notEqual(next.operationId, reserved.operationId);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('explicit operation id is stored and reused as provided', () => {
|
|
27
|
+
const reserved = reserveResumeOperation({}, 'session-3', 'carol', 'resume-op-123');
|
|
28
|
+
assert.equal(reserved.operationId, 'resume-op-123');
|
|
29
|
+
assert.equal(reserved.nextConfig.pendingResumeOperations['session-3:carol'], 'resume-op-123');
|
|
30
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { appendEvent } from '../src/supabase.js';
|
|
4
|
+
|
|
5
|
+
function createMockClient({ insertResponses, existingResponses }) {
|
|
6
|
+
const state = {
|
|
7
|
+
inserts: [],
|
|
8
|
+
selects: []
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const takeInsert = () => insertResponses.shift() ?? { data: null, error: new Error('missing insert response') };
|
|
12
|
+
const takeExisting = () => existingResponses.shift() ?? { data: null, error: null };
|
|
13
|
+
|
|
14
|
+
const client = {
|
|
15
|
+
from(table) {
|
|
16
|
+
return {
|
|
17
|
+
insert(payload) {
|
|
18
|
+
state.inserts.push({ table, payload });
|
|
19
|
+
return {
|
|
20
|
+
select() {
|
|
21
|
+
return {
|
|
22
|
+
async single() {
|
|
23
|
+
return takeInsert();
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
select(columns) {
|
|
30
|
+
const record = { table, columns, filters: [], limit: null };
|
|
31
|
+
state.selects.push(record);
|
|
32
|
+
const query = {
|
|
33
|
+
eq(column, value) {
|
|
34
|
+
record.filters.push({ column, value });
|
|
35
|
+
return query;
|
|
36
|
+
},
|
|
37
|
+
limit(value) {
|
|
38
|
+
record.limit = value;
|
|
39
|
+
return query;
|
|
40
|
+
},
|
|
41
|
+
async maybeSingle() {
|
|
42
|
+
return takeExisting();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
return query;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return { client, state };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test('appendEvent writes idempotency_key with payload idempotencyKey', async () => {
|
|
55
|
+
const inserted = {
|
|
56
|
+
id: 'event-1',
|
|
57
|
+
session_id: 'session-1',
|
|
58
|
+
type: 'resumed',
|
|
59
|
+
payload: { actor: 'alice', idempotencyKey: 'resume-op-1' },
|
|
60
|
+
created_at: '2026-03-13T00:00:00.000Z'
|
|
61
|
+
};
|
|
62
|
+
const mock = createMockClient({
|
|
63
|
+
insertResponses: [{ data: inserted, error: null }],
|
|
64
|
+
existingResponses: []
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const event = await appendEvent(
|
|
68
|
+
mock.client,
|
|
69
|
+
'session-1',
|
|
70
|
+
'resumed',
|
|
71
|
+
{ actor: 'alice' },
|
|
72
|
+
{ idempotencyKey: 'resume-op-1' }
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
assert.equal(event.id, 'event-1');
|
|
76
|
+
assert.equal(mock.state.inserts.length, 1);
|
|
77
|
+
assert.equal(mock.state.inserts[0].payload.idempotency_key, 'resume-op-1');
|
|
78
|
+
assert.equal(mock.state.inserts[0].payload.payload.idempotencyKey, 'resume-op-1');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('appendEvent returns existing row after unique violation conflict', async () => {
|
|
82
|
+
const conflictError = { code: '23505', message: 'duplicate key value violates unique constraint' };
|
|
83
|
+
const existing = {
|
|
84
|
+
id: 'event-2',
|
|
85
|
+
session_id: 'session-2',
|
|
86
|
+
type: 'resumed',
|
|
87
|
+
payload: { actor: 'bob', idempotencyKey: 'resume-op-2' },
|
|
88
|
+
created_at: '2026-03-13T00:01:00.000Z'
|
|
89
|
+
};
|
|
90
|
+
const mock = createMockClient({
|
|
91
|
+
insertResponses: [{ data: null, error: conflictError }],
|
|
92
|
+
existingResponses: [{ data: existing, error: null }]
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const event = await appendEvent(
|
|
96
|
+
mock.client,
|
|
97
|
+
'session-2',
|
|
98
|
+
'resumed',
|
|
99
|
+
{ actor: 'bob' },
|
|
100
|
+
{ idempotencyKey: 'resume-op-2' }
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
assert.equal(event.id, 'event-2');
|
|
104
|
+
assert.equal(mock.state.selects.length, 1);
|
|
105
|
+
assert.deepEqual(
|
|
106
|
+
mock.state.selects[0].filters,
|
|
107
|
+
[
|
|
108
|
+
{ column: 'session_id', value: 'session-2' },
|
|
109
|
+
{ column: 'type', value: 'resumed' },
|
|
110
|
+
{ column: 'idempotency_key', value: 'resume-op-2' }
|
|
111
|
+
]
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('appendEvent throws original conflict when existing row cannot be loaded', async () => {
|
|
116
|
+
const conflictError = { code: '23505', message: 'duplicate key value violates unique constraint' };
|
|
117
|
+
const mock = createMockClient({
|
|
118
|
+
insertResponses: [{ data: null, error: conflictError }],
|
|
119
|
+
existingResponses: [{ data: null, error: null }]
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await assert.rejects(
|
|
123
|
+
() =>
|
|
124
|
+
appendEvent(
|
|
125
|
+
mock.client,
|
|
126
|
+
'session-3',
|
|
127
|
+
'resumed',
|
|
128
|
+
{ actor: 'carol' },
|
|
129
|
+
{ idempotencyKey: 'resume-op-3' }
|
|
130
|
+
),
|
|
131
|
+
(error) => error === conflictError
|
|
132
|
+
);
|
|
133
|
+
});
|