claude-account-sync 0.1.0 → 0.2.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.
@@ -15,6 +15,38 @@ const KEY_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_AUT
15
15
  // posix: export VAR="sk-..." powershell: $env:VAR = "sk-..."
16
16
  const MIGRATE_RE_POSIX = new RegExp(`^(\\s*(?:export\\s+)?(${KEY_VARS.join('|')})\\s*=\\s*)"?(sk-[A-Za-z0-9_-]+)"?(.*)$`);
17
17
  const MIGRATE_RE_PWSH = new RegExp(`^(\\s*\\$env:(${KEY_VARS.join('|')})\\s*=\\s*)"?(sk-[A-Za-z0-9_-]+)"?(.*)$`);
18
+ /** Move plaintext keys out of the shell rc file into the secrets store. Shared by the CLI and the UI API. */
19
+ export async function migrateRcSecrets(ctx, opts = {}) {
20
+ const rcFile = ctx.platform.rcFile;
21
+ if (!existsSync(rcFile))
22
+ return [];
23
+ const store = await secretsStore(ctx);
24
+ const lines = (await readFile(rcFile, 'utf8')).split('\n');
25
+ const migrated = [];
26
+ const out = [];
27
+ for (const line of lines) {
28
+ const pwsh = line.match(MIGRATE_RE_PWSH);
29
+ const posix = pwsh ? null : line.match(MIGRATE_RE_POSIX);
30
+ const match = pwsh ?? posix;
31
+ if (!match) {
32
+ out.push(line);
33
+ continue;
34
+ }
35
+ const [, prefix, varName, secretValue, suffix] = match;
36
+ const secretName = varName.toLowerCase().replaceAll('_', '-');
37
+ if (!opts.dryRun)
38
+ await store.set(secretName, secretValue);
39
+ out.push(pwsh
40
+ ? `${prefix}(ccprofiles secrets get ${secretName})${suffix}`
41
+ : `${prefix}"$(ccprofiles secrets get ${secretName})"${suffix}`);
42
+ migrated.push(secretName);
43
+ }
44
+ if (migrated.length && !opts.dryRun) {
45
+ await backupFiles([rcFile], ctx.backupRoot, new Date().toISOString().replace(/[:.]/g, '-'));
46
+ await atomicWrite(rcFile, out.join('\n'));
47
+ }
48
+ return migrated;
49
+ }
18
50
  export function registerSecretsCommands(program, ctx) {
19
51
  const sec = program.command('secrets').description('manage secrets (values never stored in configs)');
20
52
  sec.command('set <name> [value]').action(async (name, value) => {
@@ -44,40 +76,11 @@ export function registerSecretsCommands(program, ctx) {
44
76
  console.log(`removed ${name}`);
45
77
  });
46
78
  sec.command('migrate').option('--dry-run').action(async (opts) => {
47
- const rcFile = ctx.platform.rcFile;
48
- if (!existsSync(rcFile)) {
49
- console.log('no rc file found');
50
- return;
51
- }
52
- const store = await secretsStore(ctx);
53
- const lines = (await readFile(rcFile, 'utf8')).split('\n');
54
- const migrated = [];
55
- const out = [];
56
- for (const line of lines) {
57
- const pwsh = line.match(MIGRATE_RE_PWSH);
58
- const posix = pwsh ? null : line.match(MIGRATE_RE_POSIX);
59
- const match = pwsh ?? posix;
60
- if (!match) {
61
- out.push(line);
62
- continue;
63
- }
64
- const [, prefix, varName, secretValue, suffix] = match;
65
- const secretName = varName.toLowerCase().replaceAll('_', '-');
66
- if (!opts.dryRun)
67
- await store.set(secretName, secretValue);
68
- out.push(pwsh
69
- ? `${prefix}(ccprofiles secrets get ${secretName})${suffix}`
70
- : `${prefix}"$(ccprofiles secrets get ${secretName})"${suffix}`);
71
- migrated.push(secretName);
72
- }
79
+ const migrated = await migrateRcSecrets(ctx, opts);
73
80
  if (migrated.length === 0) {
74
81
  console.log('no plaintext keys found');
75
82
  return;
76
83
  }
77
- if (!opts.dryRun) {
78
- await backupFiles([rcFile], ctx.backupRoot, new Date().toISOString().replace(/[:.]/g, '-'));
79
- await atomicWrite(rcFile, out.join('\n'));
80
- }
81
84
  for (const n of migrated)
82
85
  console.log(`${opts.dryRun ? '[dry-run] ' : ''}migrated ${n}`);
83
86
  });
package/dist/context.js CHANGED
@@ -8,6 +8,7 @@ import { registerSecretsCommands } from './commands/secrets.js';
8
8
  import { registerManifestCommands } from './commands/manifest.js';
9
9
  import { registerSyncCommands } from './commands/sync.js';
10
10
  import { registerBundleCommands } from './commands/bundle.js';
11
+ import { registerUiCommand } from './ui/command.js';
11
12
  export function makeContext(env = process.env) {
12
13
  const testHome = env.CCPROFILES_TEST_HOME;
13
14
  const platform = detectPlatform(testHome ? { home: testHome, shell: env.SHELL } : {});
@@ -38,5 +39,6 @@ export function buildProgram(ctx) {
38
39
  registerManifestCommands(program, ctx);
39
40
  registerSyncCommands(program, ctx);
40
41
  registerBundleCommands(program, ctx);
42
+ registerUiCommand(program, ctx);
41
43
  return program;
42
44
  }
package/dist/index.js CHANGED
File without changes
package/dist/ui/api.js ADDED
@@ -0,0 +1,255 @@
1
+ import { discoverProfiles, buildManifest, saveManifest, planApply, executeApply, ensureRootGitignore, loadManifest, loadDevices, fetchRemote, fetchSecrets, writeAssets, parseManifest, backupFiles, } from 'ccprofiles-core';
2
+ import { existsSync, readFileSync, lstatSync, readlinkSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { requireManifest } from '../context.js';
6
+ import { secretsStore, migrateRcSecrets } from '../commands/secrets.js';
7
+ import { sendJson, readJson, HttpError } from './http.js';
8
+ function stamp() { return new Date().toISOString().replace(/[:.]/g, '-'); }
9
+ function profileName(dirName) {
10
+ return dirName === '.claude' ? 'default' : dirName.slice('.claude-'.length);
11
+ }
12
+ export function buildRoutes(ctx) {
13
+ const routes = [];
14
+ const add = (method, pattern, handler) => routes.push({ method, pattern, handler });
15
+ async function applyAndReport(m) {
16
+ const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
17
+ return executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp() });
18
+ }
19
+ // requireManifest throws a plain Error; surface "no manifest yet" as a 409 the UI can detect.
20
+ async function mustManifest() {
21
+ try {
22
+ return await requireManifest(ctx);
23
+ }
24
+ catch (e) {
25
+ throw new HttpError(409, e.message);
26
+ }
27
+ }
28
+ function targetsOf(m, targets) {
29
+ if (targets === 'all')
30
+ return m.profiles.map(p => p.name);
31
+ for (const t of targets)
32
+ if (!m.profiles.some(p => p.name === t))
33
+ throw new HttpError(400, `unknown profile: ${t}`);
34
+ return targets;
35
+ }
36
+ // ── adopt / profiles / status / apply / doctor ──────────────────────────────
37
+ add('POST', /^\/api\/adopt$/, async (_m, _req, res) => {
38
+ const manifest = buildManifest(await discoverProfiles(ctx.home), ctx.platform);
39
+ if (!existsSync(join(ctx.manifestRoot, '.git'))) {
40
+ try {
41
+ execFileSync('git', ['init', ctx.manifestRoot], { stdio: 'ignore' });
42
+ }
43
+ catch { /* git optional */ }
44
+ }
45
+ await ensureRootGitignore(ctx.manifestRoot);
46
+ await saveManifest(ctx.manifestRoot, manifest);
47
+ sendJson(res, 200, { profiles: manifest.profiles.length, mcpServers: Object.keys(manifest.mcpServers).length });
48
+ });
49
+ add('GET', /^\/api\/profiles$/, async (_m, _req, res) => {
50
+ const live = await discoverProfiles(ctx.home);
51
+ let manifest = null;
52
+ if (existsSync(join(ctx.manifestRoot, 'manifest.yaml')))
53
+ manifest = await loadManifest(ctx.manifestRoot);
54
+ const rows = live.map(lp => {
55
+ const name = profileName(lp.dirName);
56
+ const decl = manifest?.profiles.find(p => p.name === name) ?? null;
57
+ return {
58
+ name, dir: lp.dir, auth: decl?.auth ?? (lp.account ? 'oauth' : 'env'),
59
+ account: lp.account, mcp: Object.keys(lp.mcpServers).length,
60
+ launcher: decl?.launcher ?? (name === 'default' ? null : `cl-${name}`),
61
+ adopted: !!decl,
62
+ };
63
+ });
64
+ sendJson(res, 200, rows);
65
+ });
66
+ add('POST', /^\/api\/profiles$/, async (_m, req, res) => {
67
+ const { name, from } = await readJson(req);
68
+ if (!name)
69
+ throw new HttpError(400, 'name required');
70
+ const m = await mustManifest();
71
+ if (m.profiles.some(p => p.name === name))
72
+ throw new HttpError(409, `profile exists: ${name}`);
73
+ const src = from ? m.profiles.find(p => p.name === from) : null;
74
+ if (from && !src)
75
+ throw new HttpError(400, `unknown profile: ${from}`);
76
+ m.profiles.push({
77
+ name, dir: `{home}/.claude-${name}`, launcher: `cl-${name}`, auth: 'env', env: {},
78
+ links: src ? { ...src.links } : (m.hub ? { skills: 'hub', commands: 'hub' } : {}),
79
+ mcp: src ? [...src.mcp] : [],
80
+ });
81
+ await saveManifest(ctx.manifestRoot, m);
82
+ await applyAndReport(m);
83
+ sendJson(res, 200, { ok: true });
84
+ });
85
+ add('PATCH', /^\/api\/profiles\/([^/]+)$/, async (mtch, req, res) => {
86
+ const m = await mustManifest();
87
+ const pr = m.profiles.find(p => p.name === mtch[1]);
88
+ if (!pr)
89
+ throw new HttpError(404, `unknown profile: ${mtch[1]}`);
90
+ const body = await readJson(req);
91
+ if (body.env)
92
+ pr.env = body.env;
93
+ if (body.links)
94
+ pr.links = body.links;
95
+ if (body.launcher !== undefined)
96
+ pr.launcher = body.launcher;
97
+ await saveManifest(ctx.manifestRoot, m);
98
+ await applyAndReport(m);
99
+ sendJson(res, 200, { ok: true });
100
+ });
101
+ add('GET', /^\/api\/status$/, async (_m, _req, res) => {
102
+ const m = await mustManifest();
103
+ const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
104
+ const r = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: true });
105
+ sendJson(res, 200, { inSync: actions.length === 0, pending: r.performed });
106
+ });
107
+ add('POST', /^\/api\/apply$/, async (_m, _req, res) => {
108
+ const m = await mustManifest();
109
+ const r = await applyAndReport(m);
110
+ sendJson(res, 200, { performed: r.performed, backupDir: r.backupDir });
111
+ });
112
+ add('GET', /^\/api\/doctor$/, async (_m, _req, res) => {
113
+ const problems = [];
114
+ const live = await discoverProfiles(ctx.home);
115
+ for (const lp of live)
116
+ for (const nm of readdirSync(lp.dir)) {
117
+ const f = join(lp.dir, nm);
118
+ try {
119
+ if (lstatSync(f).isSymbolicLink() && !existsSync(f))
120
+ problems.push(`broken symlink: ${f} -> ${readlinkSync(f)}`);
121
+ }
122
+ catch { /* skip */ }
123
+ }
124
+ if (existsSync(ctx.platform.rcFile)) {
125
+ const rc = readFileSync(ctx.platform.rcFile, 'utf8');
126
+ const outside = rc.split('# >>> ccprofiles managed >>>')[0] + (rc.split('# <<< ccprofiles managed <<<')[1] ?? '');
127
+ if (/sk-ant-/.test(outside))
128
+ problems.push(`plaintext Anthropic key in ${ctx.platform.rcFile} — run secrets migrate`);
129
+ }
130
+ if (existsSync(join(ctx.manifestRoot, 'manifest.yaml'))) {
131
+ const m = await loadManifest(ctx.manifestRoot);
132
+ for (const pr of m.profiles) {
133
+ const dir = pr.dir.replace('{home}', ctx.home);
134
+ if (!existsSync(dir))
135
+ problems.push(`manifest profile "${pr.name}" missing on disk: ${dir} — run apply`);
136
+ }
137
+ }
138
+ sendJson(res, 200, { problems });
139
+ });
140
+ // ── mcp ─────────────────────────────────────────────────────────────────────
141
+ add('GET', /^\/api\/mcp$/, async (_m, _req, res) => {
142
+ const m = await mustManifest();
143
+ sendJson(res, 200, {
144
+ servers: Object.keys(m.mcpServers).sort(),
145
+ profiles: m.profiles.map(p => ({ name: p.name, has: p.mcp })),
146
+ });
147
+ });
148
+ add('POST', /^\/api\/mcp$/, async (_m, req, res) => {
149
+ const { name, command, args, targets } = await readJson(req);
150
+ if (!name)
151
+ throw new HttpError(400, 'name required');
152
+ const m = await mustManifest();
153
+ if (!m.mcpServers[name]) {
154
+ if (!command)
155
+ throw new HttpError(400, `unknown server "${name}" — command required to define it`);
156
+ m.mcpServers[name] = { command, ...(args && args.length ? { args } : {}) };
157
+ }
158
+ for (const t of targetsOf(m, targets)) {
159
+ const pr = m.profiles.find(p => p.name === t);
160
+ if (!pr.mcp.includes(name))
161
+ pr.mcp.push(name);
162
+ }
163
+ await saveManifest(ctx.manifestRoot, m);
164
+ await applyAndReport(m);
165
+ sendJson(res, 200, { ok: true });
166
+ });
167
+ add('DELETE', /^\/api\/mcp\/([^/]+)$/, async (mtch, req, res) => {
168
+ const { targets } = await readJson(req);
169
+ const m = await mustManifest();
170
+ const name = decodeURIComponent(mtch[1]);
171
+ for (const t of targetsOf(m, targets)) {
172
+ const pr = m.profiles.find(p => p.name === t);
173
+ pr.mcp = pr.mcp.filter(x => x !== name);
174
+ }
175
+ if (!m.profiles.some(p => p.mcp.includes(name)))
176
+ delete m.mcpServers[name];
177
+ await saveManifest(ctx.manifestRoot, m);
178
+ await applyAndReport(m);
179
+ sendJson(res, 200, { ok: true });
180
+ });
181
+ add('POST', /^\/api\/mcp\/sync$/, async (_m, req, res) => {
182
+ const { from, to } = await readJson(req);
183
+ const m = await mustManifest();
184
+ const src = m.profiles.find(p => p.name === from);
185
+ if (!src)
186
+ throw new HttpError(400, `unknown profile: ${from}`);
187
+ for (const t of targetsOf(m, to)) {
188
+ if (t === src.name)
189
+ continue;
190
+ m.profiles.find(p => p.name === t).mcp = [...src.mcp];
191
+ }
192
+ await saveManifest(ctx.manifestRoot, m);
193
+ await applyAndReport(m);
194
+ sendJson(res, 200, { ok: true });
195
+ });
196
+ // ── secrets ─────────────────────────────────────────────────────────────────
197
+ add('GET', /^\/api\/secrets$/, async (_m, _req, res) => {
198
+ const store = await secretsStore(ctx);
199
+ sendJson(res, 200, { names: await store.list(), backend: store.backendName });
200
+ });
201
+ add('GET', /^\/api\/secrets\/([^/]+)$/, async (mtch, _req, res) => {
202
+ const store = await secretsStore(ctx);
203
+ const value = await store.get(decodeURIComponent(mtch[1]));
204
+ if (value === null)
205
+ throw new HttpError(404, 'not found');
206
+ sendJson(res, 200, { value });
207
+ });
208
+ add('PUT', /^\/api\/secrets\/([^/]+)$/, async (mtch, req, res) => {
209
+ const { value } = await readJson(req);
210
+ if (typeof value !== 'string')
211
+ throw new HttpError(400, 'value required');
212
+ const store = await secretsStore(ctx);
213
+ await store.set(decodeURIComponent(mtch[1]), value);
214
+ sendJson(res, 200, { ok: true });
215
+ });
216
+ add('DELETE', /^\/api\/secrets\/([^/]+)$/, async (mtch, _req, res) => {
217
+ const store = await secretsStore(ctx);
218
+ await store.delete(decodeURIComponent(mtch[1]));
219
+ sendJson(res, 200, { ok: true });
220
+ });
221
+ add('POST', /^\/api\/secrets\/migrate$/, async (_m, _req, res) => {
222
+ sendJson(res, 200, { migrated: await migrateRcSecrets(ctx) });
223
+ });
224
+ // ── devices / sync ──────────────────────────────────────────────────────────
225
+ add('GET', /^\/api\/devices$/, async (_m, _req, res) => {
226
+ sendJson(res, 200, await loadDevices(ctx.manifestRoot));
227
+ });
228
+ add('POST', /^\/api\/sync$/, async (_m, req, res) => {
229
+ const { from, withSecrets, dryRun } = await readJson(req);
230
+ const device = (await loadDevices(ctx.manifestRoot)).find(d => d.name === from);
231
+ if (!device)
232
+ throw new HttpError(400, `unknown device: ${from}`);
233
+ const { manifestYaml, assets } = await fetchRemote(device);
234
+ const m = parseManifest(manifestYaml);
235
+ if (!dryRun) {
236
+ await backupFiles([join(ctx.manifestRoot, 'manifest.yaml')], ctx.backupRoot, stamp());
237
+ await saveManifest(ctx.manifestRoot, m);
238
+ await writeAssets(assets, m, ctx.platform);
239
+ }
240
+ const actions = planApply(m, await discoverProfiles(ctx.home), ctx.platform);
241
+ const r = await executeApply(actions, { backupRoot: ctx.backupRoot, stamp: stamp(), dryRun: !!dryRun });
242
+ let secrets = [];
243
+ if (withSecrets) {
244
+ const values = await fetchSecrets(device, []);
245
+ if (!dryRun) {
246
+ const store = await secretsStore(ctx);
247
+ for (const [k, v] of Object.entries(values))
248
+ await store.set(k, v);
249
+ }
250
+ secrets = Object.keys(values);
251
+ }
252
+ sendJson(res, 200, { performed: r.performed, secrets });
253
+ });
254
+ return routes;
255
+ }
@@ -0,0 +1,42 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { spawn } from 'node:child_process';
5
+ import { startUiServer } from './server.js';
6
+ import { newUiToken } from './token.js';
7
+ function defaultUiDir() {
8
+ // this file compiles to dist/ui/command.js; the built frontend is copied to dist/webui
9
+ const here = dirname(fileURLToPath(import.meta.url)); // .../dist/ui
10
+ return join(dirname(here), 'webui'); // .../dist/webui
11
+ }
12
+ function openBrowser(url) {
13
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
14
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
15
+ try {
16
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
17
+ }
18
+ catch { /* non-fatal */ }
19
+ }
20
+ export function registerUiCommand(program, ctx) {
21
+ program.command('ui').description('open the web dashboard (localhost)')
22
+ .option('--port <n>', 'port (default: random)', v => parseInt(v, 10))
23
+ .option('--no-open', 'do not open the browser automatically')
24
+ .action(async (opts) => {
25
+ const uiDir = ctx.env.CCPROFILES_UI_DIR ?? defaultUiDir();
26
+ if (!existsSync(join(uiDir, 'index.html'))) {
27
+ console.log(`dashboard assets not found at ${uiDir} — build first: npm run build`);
28
+ return;
29
+ }
30
+ const token = newUiToken();
31
+ const srv = await startUiServer(ctx, { port: opts.port, token, uiDir });
32
+ const url = `http://127.0.0.1:${srv.port}/?t=${token}`;
33
+ console.log(`ccprofiles dashboard: ${url}`);
34
+ console.log('(localhost only · Ctrl-C to stop)');
35
+ if (opts.open !== false)
36
+ openBrowser(url);
37
+ globalThis.__uiServerClose = srv.close;
38
+ globalThis.__uiOnListening?.();
39
+ if (!ctx.env.CCPROFILES_UI_DIR)
40
+ await new Promise(() => { }); // run until Ctrl-C in real use
41
+ });
42
+ }
@@ -0,0 +1,42 @@
1
+ export class HttpError extends Error {
2
+ status;
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.status = status;
6
+ }
7
+ }
8
+ export class BadRequest extends HttpError {
9
+ constructor(message = 'invalid request') { super(400, message); }
10
+ }
11
+ export async function readBody(req) {
12
+ const chunks = [];
13
+ for await (const c of req)
14
+ chunks.push(c);
15
+ return Buffer.concat(chunks).toString('utf8');
16
+ }
17
+ export async function readJson(req) {
18
+ const raw = await readBody(req);
19
+ if (!raw)
20
+ return {};
21
+ try {
22
+ return JSON.parse(raw);
23
+ }
24
+ catch {
25
+ throw new BadRequest('invalid JSON body');
26
+ }
27
+ }
28
+ export function sendJson(res, code, body) {
29
+ const text = JSON.stringify(body);
30
+ res.writeHead(code, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) });
31
+ res.end(text);
32
+ }
33
+ export function matchRoute(routes, method, path) {
34
+ for (const route of routes) {
35
+ if (route.method !== method)
36
+ continue;
37
+ const match = path.match(route.pattern);
38
+ if (match)
39
+ return { route, match };
40
+ }
41
+ return null;
42
+ }
@@ -0,0 +1,36 @@
1
+ import { createServer } from 'node:http';
2
+ import { buildRoutes } from './api.js';
3
+ import { matchRoute, sendJson, HttpError } from './http.js';
4
+ import { tokenOk, originOk } from './token.js';
5
+ import { serveStatic } from './static.js';
6
+ export async function startUiServer(ctx, opts) {
7
+ const routes = buildRoutes(ctx);
8
+ const server = createServer(async (req, res) => {
9
+ const url = req.url ?? '/';
10
+ const path = url.split('?')[0];
11
+ if (!path.startsWith('/api/'))
12
+ return serveStatic(res, url, opts.uiDir);
13
+ const address = server.address();
14
+ const port = typeof address === 'object' && address ? address.port : 0;
15
+ if (!originOk(req.headers.origin, port))
16
+ return sendJson(res, 403, { error: 'bad origin' });
17
+ if (!tokenOk(req.headers['x-ccp-token'], opts.token))
18
+ return sendJson(res, 401, { error: 'unauthorized' });
19
+ const m = matchRoute(routes, req.method ?? 'GET', path);
20
+ if (!m)
21
+ return sendJson(res, 404, { error: 'not found' });
22
+ try {
23
+ await m.route.handler(m.match, req, res);
24
+ }
25
+ catch (e) {
26
+ if (e instanceof HttpError)
27
+ return sendJson(res, e.status, { error: e.message });
28
+ process.stderr.write(`ui api error: ${e.stack ?? e}\n`);
29
+ return sendJson(res, 500, { error: 'internal error' });
30
+ }
31
+ });
32
+ await new Promise(resolve => server.listen(opts.port ?? 0, '127.0.0.1', resolve));
33
+ const address = server.address();
34
+ const port = typeof address === 'object' && address ? address.port : 0;
35
+ return { port, close: () => new Promise((resolve, reject) => server.close(e => (e ? reject(e) : resolve()))) };
36
+ }
@@ -0,0 +1,22 @@
1
+ import { createReadStream, existsSync, statSync } from 'node:fs';
2
+ import { join, normalize, extname } from 'node:path';
3
+ const TYPES = {
4
+ '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
5
+ '.json': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2',
6
+ '.png': 'image/png', '.ico': 'image/x-icon', '.map': 'application/json',
7
+ };
8
+ export function serveStatic(res, urlPath, uiDir) {
9
+ const index = join(uiDir, 'index.html');
10
+ // strip query, prevent path traversal
11
+ const clean = normalize(decodeURIComponent(urlPath.split('?')[0])).replace(/^(\.\.[/\\])+/, '');
12
+ let file = join(uiDir, clean);
13
+ if (!file.startsWith(uiDir) || !existsSync(file) || statSync(file).isDirectory())
14
+ file = index;
15
+ if (!existsSync(file)) {
16
+ res.writeHead(404, { 'content-type': 'text/plain' });
17
+ res.end('UI not built');
18
+ return;
19
+ }
20
+ res.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' });
21
+ createReadStream(file).pipe(res);
22
+ }
@@ -0,0 +1,15 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
+ export function newUiToken() {
3
+ return randomBytes(32).toString('base64url');
4
+ }
5
+ export function tokenOk(provided, expected) {
6
+ if (!provided)
7
+ return false;
8
+ const a = Buffer.from(provided), b = Buffer.from(expected);
9
+ return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
10
+ }
11
+ export function originOk(origin, port) {
12
+ if (!origin)
13
+ return true;
14
+ return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;
15
+ }
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background: 0 0% 100%;--foreground: 240 10% 3.9%;--card: 0 0% 100%;--card-foreground: 240 10% 3.9%;--popover: 0 0% 100%;--popover-foreground: 240 10% 3.9%;--primary: 240 5.9% 10%;--primary-foreground: 0 0% 98%;--secondary: 240 4.8% 95.9%;--secondary-foreground: 240 5.9% 10%;--muted: 240 4.8% 95.9%;--muted-foreground: 240 3.8% 46.1%;--accent: 240 4.8% 95.9%;--accent-foreground: 240 5.9% 10%;--destructive: 0 84.2% 60.2%;--destructive-foreground: 0 0% 98%;--border: 240 5.9% 90%;--input: 240 5.9% 90%;--ring: 240 5.9% 10%;--radius: .5rem}.dark{--background: 240 10% 3.9%;--foreground: 0 0% 98%;--card: 240 10% 3.9%;--card-foreground: 0 0% 98%;--popover: 240 10% 3.9%;--popover-foreground: 0 0% 98%;--primary: 0 0% 98%;--primary-foreground: 240 5.9% 10%;--secondary: 240 3.7% 15.9%;--secondary-foreground: 0 0% 98%;--muted: 240 3.7% 15.9%;--muted-foreground: 240 5% 64.9%;--accent: 240 3.7% 15.9%;--accent-foreground: 0 0% 98%;--destructive: 0 62.8% 30.6%;--destructive-foreground: 0 0% 98%;--border: 240 3.7% 15.9%;--input: 240 3.7% 15.9%;--ring: 240 4.9% 83.9%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));font-family:ui-sans-serif,system-ui,-apple-system,sans-serif}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.left-1\/2{left:50%}.right-4{right:1rem}.top-1\/2{top:50%}.top-4{top:1rem}.z-50{z-index:50}.ml-3{margin-left:.75rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-10{height:2.5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-screen{height:100vh}.w-4{width:1rem}.w-52{width:13rem}.w-9{width:2.25rem}.w-full{width:100%}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.overflow-auto{overflow:auto}.overflow-x-auto{overflow-x:auto}.whitespace-nowrap{white-space:nowrap}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-input{border-color:hsl(var(--input))}.border-transparent{border-color:transparent}.bg-accent{background-color:hsl(var(--accent))}.bg-background{background-color:hsl(var(--background))}.bg-black\/70{background-color:#000000b3}.bg-card{background-color:hsl(var(--card))}.bg-destructive{background-color:hsl(var(--destructive))}.bg-primary{background-color:hsl(var(--primary))}.bg-secondary{background-color:hsl(var(--secondary))}.bg-transparent{background-color:transparent}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pl-5{padding-left:1.25rem}.pt-1{padding-top:.25rem}.text-left{text-align:left}.text-center{text-align:center}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.text-accent-foreground{color:hsl(var(--accent-foreground))}.text-card-foreground{color:hsl(var(--card-foreground))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-foreground{color:hsl(var(--foreground))}.text-green-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.underline-offset-4{text-underline-offset:4px}.opacity-70{opacity:.7}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}@keyframes enter{0%{opacity:var(--tw-enter-opacity, 1);transform:translate3d(var(--tw-enter-translate-x, 0),var(--tw-enter-translate-y, 0),0) scale3d(var(--tw-enter-scale, 1),var(--tw-enter-scale, 1),var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity, 1);transform:translate3d(var(--tw-exit-translate-x, 0),var(--tw-exit-translate-y, 0),0) scale3d(var(--tw-exit-scale, 1),var(--tw-exit-scale, 1),var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))}}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.last\:border-0:last-child{border-width:0px}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-accent\/50:hover{background-color:hsl(var(--accent) / .5)}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive) / .9)}.hover\:bg-muted\/50:hover{background-color:hsl(var(--muted) / .5)}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary) / .9)}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary) / .8)}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:text-foreground:hover{color:hsl(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-1:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color: hsl(var(--ring))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group.toaster .group-\[\.toaster\]\:border-border{border-color:hsl(var(--border))}.group.toaster .group-\[\.toaster\]\:bg-background{background-color:hsl(var(--background))}.group.toaster .group-\[\.toaster\]\:text-foreground{color:hsl(var(--foreground))}.group.toaster .group-\[\.toaster\]\:shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.data-\[state\=checked\]\:translate-x-4[data-state=checked]{--tw-translate-x: 1rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:hsl(var(--primary))}.data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:hsl(var(--input))}.data-\[state\=open\]\:animate-in[data-state=open]{animation-name:enter;animation-duration:.15s;--tw-enter-opacity: initial;--tw-enter-scale: initial;--tw-enter-rotate: initial;--tw-enter-translate-x: initial;--tw-enter-translate-y: initial}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation-name:exit;animation-duration:.15s;--tw-exit-opacity: initial;--tw-exit-scale: initial;--tw-exit-rotate: initial;--tw-exit-translate-x: initial;--tw-exit-translate-y: initial}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity: 0}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity: 0}@media (min-width: 640px){.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.sm\:text-left{text-align:left}}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-width:0px}.\[\&_tr\]\:border-b tr{border-bottom-width:1px}