@thesammykins/tether 1.6.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tether.ts +169 -1
- package/package.json +1 -1
- package/src/api.ts +110 -0
- package/src/bot.ts +411 -64
- package/src/config.ts +33 -1
- package/src/db.ts +107 -1
- package/src/features/projects.ts +219 -0
- package/src/queue.ts +1 -0
- package/src/worker.ts +22 -4
package/bin/tether.ts
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
deleteKey as deleteConfigKey, resolve as resolveConfig, resolveAll,
|
|
43
43
|
isKnownKey, isSecret, getKeyMeta, getKnownKeys, hasSecrets, hasConfig,
|
|
44
44
|
importDotEnv, CONFIG_PATHS,
|
|
45
|
-
} from '../src/config';
|
|
45
|
+
} from '../src/config.js';
|
|
46
46
|
|
|
47
47
|
const PID_FILE = join(process.cwd(), '.tether.pid');
|
|
48
48
|
const API_BASE = process.env.TETHER_API_URL || 'http://localhost:2643';
|
|
@@ -674,6 +674,164 @@ async function updateState() {
|
|
|
674
674
|
|
|
675
675
|
// ============ Management Commands ============
|
|
676
676
|
|
|
677
|
+
async function projectCommand() {
|
|
678
|
+
const subcommand = args[0];
|
|
679
|
+
|
|
680
|
+
switch (subcommand) {
|
|
681
|
+
case 'add': {
|
|
682
|
+
const name = args[1];
|
|
683
|
+
const rawPath = args[2];
|
|
684
|
+
if (!name || !rawPath) {
|
|
685
|
+
console.error('Usage: tether project add <name> <path>');
|
|
686
|
+
process.exit(1);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const { resolve: resolvePath } = await import('path');
|
|
690
|
+
const resolvedPath = resolvePath(rawPath);
|
|
691
|
+
|
|
692
|
+
if (!existsSync(resolvedPath)) {
|
|
693
|
+
console.error(`Error: Path does not exist: ${resolvedPath}`);
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
try {
|
|
698
|
+
const response = await fetch(`${API_BASE}/projects`, {
|
|
699
|
+
method: 'POST',
|
|
700
|
+
headers: buildApiHeaders(),
|
|
701
|
+
body: JSON.stringify({ name, path: resolvedPath }),
|
|
702
|
+
});
|
|
703
|
+
const data = await response.json() as Record<string, unknown>;
|
|
704
|
+
if (!response.ok || data.error) {
|
|
705
|
+
console.error('Error:', data.error || 'Request failed');
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
console.log(`Project "${name}" added: ${resolvedPath}`);
|
|
709
|
+
} catch (error: unknown) {
|
|
710
|
+
const err = error as { code?: string; message?: string };
|
|
711
|
+
if (err.code === 'ECONNREFUSED') {
|
|
712
|
+
console.error('Error: Cannot connect to Tether API. Is the bot running? (tether start)');
|
|
713
|
+
} else {
|
|
714
|
+
console.error('Error:', err.message);
|
|
715
|
+
}
|
|
716
|
+
process.exit(1);
|
|
717
|
+
}
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
case 'list': {
|
|
722
|
+
try {
|
|
723
|
+
const response = await fetch(`${API_BASE}/projects`, {
|
|
724
|
+
headers: buildApiHeaders(),
|
|
725
|
+
});
|
|
726
|
+
const projects = await response.json() as Array<{
|
|
727
|
+
name: string;
|
|
728
|
+
path: string;
|
|
729
|
+
is_default: number;
|
|
730
|
+
}>;
|
|
731
|
+
if (!response.ok) {
|
|
732
|
+
console.error('Error: Failed to list projects');
|
|
733
|
+
process.exit(1);
|
|
734
|
+
}
|
|
735
|
+
if (projects.length === 0) {
|
|
736
|
+
console.log('No projects registered. Add one with: tether project add <name> <path>');
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
console.log('\nProjects:\n');
|
|
740
|
+
for (const p of projects) {
|
|
741
|
+
const marker = p.is_default ? ' (default)' : '';
|
|
742
|
+
console.log(` ${p.name}${marker}`);
|
|
743
|
+
console.log(` ${p.path}\n`);
|
|
744
|
+
}
|
|
745
|
+
} catch (error: unknown) {
|
|
746
|
+
const err = error as { code?: string; message?: string };
|
|
747
|
+
if (err.code === 'ECONNREFUSED') {
|
|
748
|
+
console.error('Error: Cannot connect to Tether API. Is the bot running? (tether start)');
|
|
749
|
+
} else {
|
|
750
|
+
console.error('Error:', err.message);
|
|
751
|
+
}
|
|
752
|
+
process.exit(1);
|
|
753
|
+
}
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
case 'remove': {
|
|
758
|
+
const name = args[1];
|
|
759
|
+
if (!name) {
|
|
760
|
+
console.error('Usage: tether project remove <name>');
|
|
761
|
+
process.exit(1);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
try {
|
|
765
|
+
const response = await fetch(`${API_BASE}/projects/${encodeURIComponent(name)}`, {
|
|
766
|
+
method: 'DELETE',
|
|
767
|
+
headers: buildApiHeaders(),
|
|
768
|
+
});
|
|
769
|
+
const data = await response.json() as Record<string, unknown>;
|
|
770
|
+
if (!response.ok || data.error) {
|
|
771
|
+
console.error('Error:', data.error || 'Request failed');
|
|
772
|
+
process.exit(1);
|
|
773
|
+
}
|
|
774
|
+
console.log(`Project "${name}" removed.`);
|
|
775
|
+
} catch (error: unknown) {
|
|
776
|
+
const err = error as { code?: string; message?: string };
|
|
777
|
+
if (err.code === 'ECONNREFUSED') {
|
|
778
|
+
console.error('Error: Cannot connect to Tether API. Is the bot running? (tether start)');
|
|
779
|
+
} else {
|
|
780
|
+
console.error('Error:', err.message);
|
|
781
|
+
}
|
|
782
|
+
process.exit(1);
|
|
783
|
+
}
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
case 'set-default': {
|
|
788
|
+
const name = args[1];
|
|
789
|
+
if (!name) {
|
|
790
|
+
console.error('Usage: tether project set-default <name>');
|
|
791
|
+
process.exit(1);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
try {
|
|
795
|
+
const response = await fetch(`${API_BASE}/projects/${encodeURIComponent(name)}/default`, {
|
|
796
|
+
method: 'POST',
|
|
797
|
+
headers: buildApiHeaders(),
|
|
798
|
+
});
|
|
799
|
+
const data = await response.json() as Record<string, unknown>;
|
|
800
|
+
if (!response.ok || data.error) {
|
|
801
|
+
console.error('Error:', data.error || 'Request failed');
|
|
802
|
+
process.exit(1);
|
|
803
|
+
}
|
|
804
|
+
console.log(`Project "${name}" set as default.`);
|
|
805
|
+
} catch (error: unknown) {
|
|
806
|
+
const err = error as { code?: string; message?: string };
|
|
807
|
+
if (err.code === 'ECONNREFUSED') {
|
|
808
|
+
console.error('Error: Cannot connect to Tether API. Is the bot running? (tether start)');
|
|
809
|
+
} else {
|
|
810
|
+
console.error('Error:', err.message);
|
|
811
|
+
}
|
|
812
|
+
process.exit(1);
|
|
813
|
+
}
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
default:
|
|
818
|
+
console.log(`
|
|
819
|
+
Usage: tether project <subcommand>
|
|
820
|
+
|
|
821
|
+
Subcommands:
|
|
822
|
+
add <name> <path> Register a project (validates path exists)
|
|
823
|
+
list List all registered projects
|
|
824
|
+
remove <name> Remove a project
|
|
825
|
+
set-default <name> Set a project as the default
|
|
826
|
+
`);
|
|
827
|
+
if (subcommand) {
|
|
828
|
+
console.error(`Unknown project subcommand: ${subcommand}`);
|
|
829
|
+
process.exit(1);
|
|
830
|
+
}
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
677
835
|
async function setup() {
|
|
678
836
|
console.log('\n🔌 Tether Setup\n');
|
|
679
837
|
|
|
@@ -1268,6 +1426,7 @@ Management Commands:
|
|
|
1268
1426
|
health Check Distether connection
|
|
1269
1427
|
setup Interactive setup wizard
|
|
1270
1428
|
config Manage configuration and encrypted secrets
|
|
1429
|
+
project Manage named projects
|
|
1271
1430
|
help Show this help
|
|
1272
1431
|
|
|
1273
1432
|
Distether Commands:
|
|
@@ -1349,6 +1508,12 @@ Config Commands:
|
|
|
1349
1508
|
config import [path] Import from .env file (default: ./.env)
|
|
1350
1509
|
config path Show config file locations
|
|
1351
1510
|
|
|
1511
|
+
Project Commands:
|
|
1512
|
+
project add <name> <path> Register a named project directory
|
|
1513
|
+
project list List all registered projects
|
|
1514
|
+
project remove <name> Remove a project
|
|
1515
|
+
project set-default <name> Set a project as the default
|
|
1516
|
+
|
|
1352
1517
|
Examples:
|
|
1353
1518
|
tether send 123456789 "Hello world!"
|
|
1354
1519
|
tether embed 123456789 "Status update" --title "Daily Report" --color green --field "Tasks:5 done:inline"
|
|
@@ -1439,6 +1604,9 @@ switch (command) {
|
|
|
1439
1604
|
case 'config':
|
|
1440
1605
|
configCommand();
|
|
1441
1606
|
break;
|
|
1607
|
+
case 'project':
|
|
1608
|
+
projectCommand();
|
|
1609
|
+
break;
|
|
1442
1610
|
|
|
1443
1611
|
default:
|
|
1444
1612
|
console.log(`Unknown command: ${command}`);
|
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import { Client, TextChannel, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
|
|
9
9
|
import { timingSafeEqual, createHmac } from 'crypto';
|
|
10
|
+
import { existsSync } from 'fs';
|
|
11
|
+
import { resolve } from 'path';
|
|
12
|
+
import {
|
|
13
|
+
listProjects, createProject, deleteProject, setProjectDefault,
|
|
14
|
+
getProject,
|
|
15
|
+
} from './db.js';
|
|
10
16
|
|
|
11
17
|
const log = (msg: string) => process.stdout.write(`[api] ${msg}\n`);
|
|
12
18
|
|
|
@@ -559,6 +565,110 @@ export function startApiServer(client: Client, port: number = 2643) {
|
|
|
559
565
|
}), { headers });
|
|
560
566
|
}
|
|
561
567
|
|
|
568
|
+
// --- Project management endpoints ---
|
|
569
|
+
|
|
570
|
+
// GET /projects — list all projects
|
|
571
|
+
if (url.pathname === '/projects' && req.method === 'GET') {
|
|
572
|
+
const projects = listProjects();
|
|
573
|
+
return new Response(JSON.stringify(projects), { headers });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// POST /projects — create a project
|
|
577
|
+
if (url.pathname === '/projects' && req.method === 'POST') {
|
|
578
|
+
try {
|
|
579
|
+
const body = await req.json() as {
|
|
580
|
+
name?: string;
|
|
581
|
+
path?: string;
|
|
582
|
+
isDefault?: boolean;
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
if (!body.name || !body.path) {
|
|
586
|
+
return new Response(JSON.stringify({ error: 'name and path are required' }), {
|
|
587
|
+
status: 400,
|
|
588
|
+
headers,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Validate path exists on disk
|
|
593
|
+
const resolvedPath = resolve(body.path);
|
|
594
|
+
if (!existsSync(resolvedPath)) {
|
|
595
|
+
return new Response(JSON.stringify({ error: `Path does not exist: ${resolvedPath}` }), {
|
|
596
|
+
status: 400,
|
|
597
|
+
headers,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
createProject(body.name, resolvedPath, body.isDefault);
|
|
602
|
+
const project = getProject(body.name);
|
|
603
|
+
log(`Project created: ${body.name} → ${resolvedPath}`);
|
|
604
|
+
return new Response(JSON.stringify({ success: true, project }), {
|
|
605
|
+
status: 201,
|
|
606
|
+
headers,
|
|
607
|
+
});
|
|
608
|
+
} catch (error) {
|
|
609
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
610
|
+
// Surface unique constraint violations as 409 Conflict
|
|
611
|
+
if (message.includes('UNIQUE constraint')) {
|
|
612
|
+
return new Response(JSON.stringify({ error: `Project already exists` }), {
|
|
613
|
+
status: 409,
|
|
614
|
+
headers,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
log(`Create project error: ${error instanceof Error ? error.stack : message}`);
|
|
618
|
+
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
|
619
|
+
status: 500,
|
|
620
|
+
headers,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// DELETE /projects/:name — delete a project
|
|
626
|
+
if (url.pathname.startsWith('/projects/') && req.method === 'DELETE') {
|
|
627
|
+
const name = decodeURIComponent(url.pathname.slice('/projects/'.length).split('/')[0] || '');
|
|
628
|
+
if (!name) {
|
|
629
|
+
return new Response(JSON.stringify({ error: 'Project name is required' }), {
|
|
630
|
+
status: 400,
|
|
631
|
+
headers,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const existing = getProject(name);
|
|
636
|
+
if (!existing) {
|
|
637
|
+
return new Response(JSON.stringify({ error: `Project "${name}" not found` }), {
|
|
638
|
+
status: 404,
|
|
639
|
+
headers,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
deleteProject(name);
|
|
644
|
+
log(`Project deleted: ${name}`);
|
|
645
|
+
return new Response(JSON.stringify({ success: true }), { headers });
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// POST /projects/:name/default — set project as default
|
|
649
|
+
if (url.pathname.match(/^\/projects\/[^/]+\/default$/) && req.method === 'POST') {
|
|
650
|
+
const parts = url.pathname.split('/');
|
|
651
|
+
const name = decodeURIComponent(parts[2] || '');
|
|
652
|
+
if (!name) {
|
|
653
|
+
return new Response(JSON.stringify({ error: 'Project name is required' }), {
|
|
654
|
+
status: 400,
|
|
655
|
+
headers,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const existing = getProject(name);
|
|
660
|
+
if (!existing) {
|
|
661
|
+
return new Response(JSON.stringify({ error: `Project "${name}" not found` }), {
|
|
662
|
+
status: 404,
|
|
663
|
+
headers,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
setProjectDefault(name);
|
|
668
|
+
log(`Project set as default: ${name}`);
|
|
669
|
+
return new Response(JSON.stringify({ success: true }), { headers });
|
|
670
|
+
}
|
|
671
|
+
|
|
562
672
|
// 404 for unknown routes
|
|
563
673
|
return new Response(JSON.stringify({ error: 'Not found' }), {
|
|
564
674
|
status: 404,
|