tokrepo 2.0.0 → 3.0.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.
Files changed (2) hide show
  1. package/bin/tokrepo.js +398 -6
  2. package/package.json +2 -2
package/bin/tokrepo.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const crypto = require('crypto');
5
6
  const https = require('https');
6
7
  const http = require('http');
7
8
  const readline = require('readline');
@@ -23,6 +24,8 @@ const CONFIG_DIR = path.join(require('os').homedir(), '.tokrepo');
23
24
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
24
25
  const PROJECT_CONFIG = '.tokrepo.json';
25
26
  const DEFAULT_API = 'https://api.tokrepo.com';
27
+ const CLI_VERSION = '3.0.0';
28
+ const VERSION_CHECK_FILE = path.join(require('os').homedir(), '.tokrepo', '.version-check');
26
29
 
27
30
  // ─── Helpers ───
28
31
 
@@ -42,9 +45,64 @@ function readConfig() {
42
45
 
43
46
  function writeConfig(config) {
44
47
  if (!fs.existsSync(CONFIG_DIR)) {
45
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
48
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
46
49
  }
47
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
50
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
51
+ }
52
+
53
+ // Check npm registry for newer version (non-blocking, max once per day)
54
+ async function checkForUpdate() {
55
+ try {
56
+ // Only check once per 24 hours
57
+ if (fs.existsSync(VERSION_CHECK_FILE)) {
58
+ const stat = fs.statSync(VERSION_CHECK_FILE);
59
+ const hoursSinceCheck = (Date.now() - stat.mtimeMs) / 3600000;
60
+ if (hoursSinceCheck < 24) return;
61
+ }
62
+ // Touch the file to mark check time
63
+ if (!fs.existsSync(path.dirname(VERSION_CHECK_FILE))) {
64
+ fs.mkdirSync(path.dirname(VERSION_CHECK_FILE), { recursive: true, mode: 0o700 });
65
+ }
66
+ fs.writeFileSync(VERSION_CHECK_FILE, '', { mode: 0o600 });
67
+
68
+ const data = await new Promise((resolve, reject) => {
69
+ const req = https.get('https://registry.npmjs.org/tokrepo/latest', {
70
+ headers: { 'Accept': 'application/json' },
71
+ timeout: 3000,
72
+ }, (res) => {
73
+ let body = '';
74
+ res.on('data', (chunk) => { body += chunk; });
75
+ res.on('end', () => {
76
+ try { resolve(JSON.parse(body)); } catch { reject(); }
77
+ });
78
+ });
79
+ req.on('error', reject);
80
+ req.on('timeout', () => { req.destroy(); reject(); });
81
+ });
82
+
83
+ const latest = data.version;
84
+ if (latest && latest !== CLI_VERSION) {
85
+ const cmp = compareVersions(latest, CLI_VERSION);
86
+ if (cmp > 0) {
87
+ log('');
88
+ log(`${C.yellow}!${C.reset} Update available: ${C.dim}${CLI_VERSION}${C.reset} → ${C.green}${latest}${C.reset}`);
89
+ log(` Run: ${C.cyan}npm install -g tokrepo${C.reset}`);
90
+ log('');
91
+ }
92
+ }
93
+ } catch {
94
+ // Silent fail — update check is best-effort
95
+ }
96
+ }
97
+
98
+ function compareVersions(a, b) {
99
+ const pa = a.split('.').map(Number);
100
+ const pb = b.split('.').map(Number);
101
+ for (let i = 0; i < 3; i++) {
102
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
103
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
104
+ }
105
+ return 0;
48
106
  }
49
107
 
50
108
  function readProjectConfig(baseDir = process.cwd()) {
@@ -74,6 +132,10 @@ function apiRequest(method, urlPath, body, token, apiBase) {
74
132
  return new Promise((resolve, reject) => {
75
133
  const base = apiBase || DEFAULT_API;
76
134
  const url = new URL(urlPath, base);
135
+ // Force HTTPS to prevent token transmission over plain HTTP
136
+ if (url.protocol === 'http:' && !url.hostname.match(/^(localhost|127\.0\.0\.1)$/)) {
137
+ url.protocol = 'https:';
138
+ }
77
139
  const isHttps = url.protocol === 'https:';
78
140
  const mod = isHttps ? https : http;
79
141
 
@@ -144,7 +206,8 @@ function detectFileType(filename) {
144
206
 
145
207
  // Guess tag from file type
146
208
  function guessTag(fileType) {
147
- const map = { skill: 'Skills', prompt: 'Prompts', script: 'Scripts', config: 'Configs' };
209
+ // Use lowercase singular names to match backend tag slugs
210
+ const map = { skill: 'skill', prompt: 'prompt', script: 'script', config: 'config', mcp: 'mcp' };
148
211
  return map[fileType] || null;
149
212
  }
150
213
 
@@ -253,6 +316,11 @@ function collectFiles(paths, baseDir) {
253
316
 
254
317
  for (const p of paths) {
255
318
  const resolved = path.resolve(baseDir, p);
319
+ // Prevent path traversal — resolved must be within baseDir
320
+ if (!resolved.startsWith(path.resolve(baseDir) + path.sep) && resolved !== path.resolve(baseDir)) {
321
+ warn(`Skipped (outside project): ${p}`);
322
+ continue;
323
+ }
256
324
  if (!fs.existsSync(resolved)) {
257
325
  warn(`Not found: ${p}`);
258
326
  continue;
@@ -674,6 +742,9 @@ Examples:
674
742
  }
675
743
  case 'mcp':
676
744
  case 'mcp configs': {
745
+ // Security warning for MCP configs
746
+ warn('MCP server config detected. Review the configuration carefully before adding to your project.');
747
+ warn('MCP servers can execute arbitrary code. Only install from trusted sources.');
677
748
  // Save as mcp config, hint about manual merge
678
749
  if (!fileName.endsWith('.json')) fileName = fileName.replace(/\.md$/, '.json');
679
750
  break;
@@ -704,6 +775,8 @@ Examples:
704
775
  }
705
776
  }
706
777
 
778
+ // Sanitize fileName to prevent path traversal from API response
779
+ fileName = path.basename(fileName);
707
780
  const destPath = path.join(destDir, fileName);
708
781
 
709
782
  // Don't overwrite without warning
@@ -848,6 +921,306 @@ async function cmdTags() {
848
921
  }
849
922
  }
850
923
 
924
+ // ─── Sync: scan directory, diff with remote, upsert changes ───
925
+
926
+ function computeHash(files) {
927
+ const h = crypto.createHash('sha256');
928
+ for (const f of files) {
929
+ h.update(f.name);
930
+ h.update('\0');
931
+ h.update(f.content);
932
+ h.update('\0');
933
+ }
934
+ return h.digest('hex');
935
+ }
936
+
937
+ function scanDirectory(dirPath) {
938
+ const assets = [];
939
+ const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', '.venv', 'dist', 'build']);
940
+ const SKIP_FILES = new Set(['.DS_Store', 'Thumbs.db', 'package-lock.json', 'yarn.lock']);
941
+
942
+ // Each subdirectory = one asset; loose files = one asset per file
943
+ let entries;
944
+ try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return assets; }
945
+
946
+ const looseFiles = [];
947
+
948
+ for (const entry of entries) {
949
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name)) continue;
950
+
951
+ const fullPath = path.join(dirPath, entry.name);
952
+
953
+ if (entry.isDirectory()) {
954
+ // Subdirectory = one asset (e.g., ~/.claude/skills/my-skill/)
955
+ const files = collectAssetFiles(fullPath);
956
+ if (files.length === 0) continue;
957
+
958
+ const title = guessAssetTitle(files, entry.name);
959
+ const hash = computeHash(files);
960
+ const detectedTags = new Set();
961
+ for (const f of files) {
962
+ const ft = detectFileType(f.name);
963
+ const tag = guessTag(ft);
964
+ if (tag) detectedTags.add(tag);
965
+ }
966
+
967
+ assets.push({ title, files, hash, tags: Array.from(detectedTags), sourcePath: fullPath });
968
+ } else if (entry.isFile()) {
969
+ // Loose file
970
+ const ext = path.extname(entry.name).toLowerCase();
971
+ const validExts = ['.md', '.sh', '.py', '.js', '.mjs', '.ts', '.json', '.yaml', '.yml', '.toml', '.prompt'];
972
+ if (validExts.includes(ext) || entry.name === '.cursorrules' || entry.name === '.windsurfrules') {
973
+ looseFiles.push({ path: fullPath, name: entry.name });
974
+ }
975
+ }
976
+ }
977
+
978
+ // Each loose file = one asset
979
+ for (const f of looseFiles) {
980
+ let content;
981
+ try { content = fs.readFileSync(f.path, 'utf8'); } catch { continue; }
982
+ if (!content.trim()) continue;
983
+
984
+ const files = [{ name: f.name, content, type: detectFileType(f.name) }];
985
+ const title = guessAssetTitle(files, path.basename(f.name, path.extname(f.name)));
986
+ const hash = computeHash(files);
987
+ const ft = detectFileType(f.name);
988
+ const tag = guessTag(ft);
989
+
990
+ assets.push({ title, files, hash, tags: tag ? [tag] : [], sourcePath: f.path });
991
+ }
992
+
993
+ return assets;
994
+ }
995
+
996
+ function collectAssetFiles(dirPath) {
997
+ const files = [];
998
+ const SKIP = new Set(['.DS_Store', 'node_modules', '.git', '__pycache__']);
999
+
1000
+ function walk(dir, relBase) {
1001
+ let entries;
1002
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
1003
+ for (const entry of entries) {
1004
+ if (entry.name.startsWith('.') || SKIP.has(entry.name)) continue;
1005
+ const fullPath = path.join(dir, entry.name);
1006
+ const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
1007
+ if (entry.isDirectory()) {
1008
+ walk(fullPath, relPath);
1009
+ } else if (entry.isFile()) {
1010
+ const ext = path.extname(entry.name).toLowerCase();
1011
+ const validExts = ['.md', '.sh', '.py', '.js', '.mjs', '.ts', '.json', '.yaml', '.yml', '.toml', '.prompt', '.rb', '.go', '.rs'];
1012
+ if (validExts.includes(ext) || entry.name === '.cursorrules') {
1013
+ let content;
1014
+ try { content = fs.readFileSync(fullPath, 'utf8'); } catch { continue; }
1015
+ if (!content.trim()) continue;
1016
+ files.push({ name: relPath, content, type: detectFileType(relPath) });
1017
+ }
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ walk(dirPath, '');
1023
+ return files;
1024
+ }
1025
+
1026
+ function guessAssetTitle(files, fallbackName) {
1027
+ // Try to find a heading in the first .md file
1028
+ for (const f of files) {
1029
+ if (f.name.toLowerCase().endsWith('.md')) {
1030
+ const match = f.content.match(/^#\s+(.+)$/m);
1031
+ if (match) return match[1].trim();
1032
+ }
1033
+ }
1034
+ // Clean up fallback name
1035
+ return fallbackName
1036
+ .replace(/[-_]/g, ' ')
1037
+ .replace(/\b\w/g, c => c.toUpperCase());
1038
+ }
1039
+
1040
+ async function cmdSync() {
1041
+ const args = parseArgs(process.argv);
1042
+ const config = readConfig();
1043
+ if (!config || !config.token) error(`Not logged in. Run: ${C.cyan}tokrepo login${C.reset}`);
1044
+
1045
+ const targetDir = args.positional[0]
1046
+ ? path.resolve(args.positional[0])
1047
+ : path.join(require('os').homedir(), '.claude', 'skills');
1048
+
1049
+ if (!fs.existsSync(targetDir)) {
1050
+ error(`Directory not found: ${targetDir}`);
1051
+ }
1052
+
1053
+ const visibility = args.flags.public ? 1 : 0; // default private for sync
1054
+
1055
+ log(`\n${C.bold}tokrepo sync${C.reset}\n`);
1056
+ info(`Scanning ${targetDir}...`);
1057
+
1058
+ const assets = scanDirectory(targetDir);
1059
+ if (assets.length === 0) {
1060
+ info('No assets found in directory.');
1061
+ return;
1062
+ }
1063
+
1064
+ log(` Found ${C.bold}${assets.length}${C.reset} assets\n`);
1065
+
1066
+ // Step 1: Diff with remote
1067
+ info('Comparing with remote...');
1068
+ const diffPayload = assets.map(a => ({ title: a.title, content_hash: a.hash }));
1069
+
1070
+ let diffResults;
1071
+ try {
1072
+ const data = await apiRequest('POST', '/api/v1/tokenboard/push/diff', { assets: diffPayload }, config.token, config.api);
1073
+ diffResults = data.results;
1074
+ } catch (e) {
1075
+ // Fallback: if diff endpoint not available, treat all as new
1076
+ warn(`Diff API not available (${e.message}), treating all as new`);
1077
+ diffResults = assets.map(a => ({ title: a.title, status: 'new' }));
1078
+ }
1079
+
1080
+ // Build status map
1081
+ const statusMap = {};
1082
+ for (const r of diffResults) {
1083
+ statusMap[r.title] = r;
1084
+ }
1085
+
1086
+ // Show status
1087
+ let newCount = 0, updatedCount = 0, unchangedCount = 0;
1088
+
1089
+ for (const asset of assets) {
1090
+ const status = statusMap[asset.title]?.status || 'new';
1091
+ if (status === 'new') {
1092
+ log(` ${C.green}+ new${C.reset} ${asset.title} ${C.dim}(${asset.files.length} files)${C.reset}`);
1093
+ newCount++;
1094
+ } else if (status === 'updated') {
1095
+ log(` ${C.yellow}~ updated${C.reset} ${asset.title}`);
1096
+ updatedCount++;
1097
+ } else {
1098
+ log(` ${C.dim}= unchanged ${asset.title}${C.reset}`);
1099
+ unchangedCount++;
1100
+ }
1101
+ }
1102
+
1103
+ log('');
1104
+ log(` ${C.green}${newCount} new${C.reset} ${C.yellow}${updatedCount} updated${C.reset} ${C.dim}${unchangedCount} unchanged${C.reset}`);
1105
+
1106
+ if (newCount === 0 && updatedCount === 0) {
1107
+ log('');
1108
+ success('Everything is up to date!');
1109
+ return;
1110
+ }
1111
+
1112
+ log('');
1113
+
1114
+ // Confirm unless -y
1115
+ if (!args.flags.yes) {
1116
+ const confirm = await ask(`Push ${newCount + updatedCount} assets? (y/N):`);
1117
+ if (confirm.toLowerCase() !== 'y') { log('Aborted.'); return; }
1118
+ }
1119
+
1120
+ // Step 2: Upsert each changed asset
1121
+ let successCount = 0;
1122
+ let failCount = 0;
1123
+
1124
+ for (const asset of assets) {
1125
+ const status = statusMap[asset.title]?.status || 'new';
1126
+ if (status === 'unchanged') continue;
1127
+
1128
+ const totalChars = asset.files.reduce((sum, f) => sum + f.content.length, 0);
1129
+
1130
+ try {
1131
+ const data = await apiRequest('POST', '/api/v1/tokenboard/push/upsert', {
1132
+ title: asset.title,
1133
+ files: asset.files,
1134
+ tags: asset.tags,
1135
+ token_cost: String(Math.round(totalChars / 4)),
1136
+ visibility,
1137
+ }, config.token, config.api);
1138
+
1139
+ const action = data.action === 'created' ? C.green + '+ created' : C.yellow + '~ updated';
1140
+ log(` ${action}${C.reset} ${asset.title} ${C.dim}${data.url}${C.reset}`);
1141
+ successCount++;
1142
+ } catch (e) {
1143
+ log(` ${C.red}✗ failed${C.reset} ${asset.title}: ${e.message}`);
1144
+ failCount++;
1145
+ }
1146
+ }
1147
+
1148
+ log('');
1149
+ if (failCount === 0) {
1150
+ success(`Synced ${successCount} assets!`);
1151
+ } else {
1152
+ warn(`${successCount} synced, ${failCount} failed`);
1153
+ }
1154
+ log('');
1155
+ }
1156
+
1157
+ async function cmdStatus() {
1158
+ const args = parseArgs(process.argv);
1159
+ const config = readConfig();
1160
+ if (!config || !config.token) error(`Not logged in. Run: ${C.cyan}tokrepo login${C.reset}`);
1161
+
1162
+ const targetDir = args.positional[0]
1163
+ ? path.resolve(args.positional[0])
1164
+ : path.join(require('os').homedir(), '.claude', 'skills');
1165
+
1166
+ if (!fs.existsSync(targetDir)) {
1167
+ error(`Directory not found: ${targetDir}`);
1168
+ }
1169
+
1170
+ log(`\n${C.bold}tokrepo status${C.reset}\n`);
1171
+ info(`Scanning ${targetDir}...`);
1172
+
1173
+ const assets = scanDirectory(targetDir);
1174
+ if (assets.length === 0) {
1175
+ info('No assets found in directory.');
1176
+ return;
1177
+ }
1178
+
1179
+ // Diff with remote
1180
+ info('Comparing with remote...\n');
1181
+ const diffPayload = assets.map(a => ({ title: a.title, content_hash: a.hash }));
1182
+
1183
+ let diffResults;
1184
+ try {
1185
+ const data = await apiRequest('POST', '/api/v1/tokenboard/push/diff', { assets: diffPayload }, config.token, config.api);
1186
+ diffResults = data.results;
1187
+ } catch (e) {
1188
+ error(`Diff API error: ${e.message}`);
1189
+ }
1190
+
1191
+ const statusMap = {};
1192
+ for (const r of diffResults) {
1193
+ statusMap[r.title] = r;
1194
+ }
1195
+
1196
+ let newCount = 0, updatedCount = 0, unchangedCount = 0;
1197
+
1198
+ for (const asset of assets) {
1199
+ const status = statusMap[asset.title]?.status || 'new';
1200
+ const uuid = statusMap[asset.title]?.remote_uuid || '';
1201
+ const uuidShort = uuid ? ` ${C.dim}(${uuid.substring(0, 8)})${C.reset}` : '';
1202
+
1203
+ if (status === 'new') {
1204
+ log(` ${C.green}+ new${C.reset} ${asset.title} ${C.dim}(${asset.files.length} files)${C.reset}`);
1205
+ newCount++;
1206
+ } else if (status === 'updated') {
1207
+ log(` ${C.yellow}~ modified${C.reset} ${asset.title}${uuidShort}`);
1208
+ updatedCount++;
1209
+ } else {
1210
+ log(` ${C.dim} unchanged ${asset.title}${uuidShort}${C.reset}`);
1211
+ unchangedCount++;
1212
+ }
1213
+ }
1214
+
1215
+ log('');
1216
+ log(` ${C.bold}${assets.length}${C.reset} local assets: ${C.green}${newCount} new${C.reset} ${C.yellow}${updatedCount} modified${C.reset} ${C.dim}${unchangedCount} synced${C.reset}`);
1217
+
1218
+ if (newCount > 0 || updatedCount > 0) {
1219
+ log(`\n Run ${C.cyan}tokrepo sync ${args.positional[0] || ''}${C.reset} to push changes`);
1220
+ }
1221
+ log('');
1222
+ }
1223
+
851
1224
  function showHelp() {
852
1225
  log(`
853
1226
  ${C.bold}tokrepo${C.reset} — AI assets for humans and agents. Like GitHub, for AI experience.
@@ -865,10 +1238,12 @@ ${C.bold}DISCOVER & INSTALL${C.reset}
865
1238
  ${C.cyan}install${C.reset} <name|uuid> Smart install (auto-detects type & placement)
866
1239
  ${C.cyan}pull${C.reset} <url|uuid> Download raw asset files
867
1240
 
868
- ${C.bold}PUBLISH${C.reset}
869
- ${C.cyan}push${C.reset} [files...] Push files/directory to TokRepo
1241
+ ${C.bold}PUBLISH & SYNC${C.reset}
1242
+ ${C.cyan}push${C.reset} [files...] Push files/directory (creates new asset)
1243
+ ${C.cyan}sync${C.reset} [dir] Sync directory to TokRepo (smart upsert)
1244
+ ${C.cyan}status${C.reset} [dir] Show local vs remote diff
870
1245
  ${C.cyan}init${C.reset} Create .tokrepo.json project config
871
- ${C.cyan}update${C.reset} <uuid> [f] Update existing asset
1246
+ ${C.cyan}update${C.reset} <uuid> [f] Update existing asset by UUID
872
1247
  ${C.cyan}delete${C.reset} <uuid> Delete an asset
873
1248
 
874
1249
  ${C.bold}ACCOUNT${C.reset}
@@ -892,6 +1267,16 @@ ${C.bold}INSTALL BEHAVIOR${C.reset}
892
1267
  MCP → current dir (.json)
893
1268
  Prompts → current dir (.md)
894
1269
 
1270
+ ${C.bold}SYNC (the killer feature)${C.reset}
1271
+ ${C.cyan}tokrepo sync ~/.claude/skills/${C.reset} # Sync all skills (default: private)
1272
+ ${C.cyan}tokrepo sync ~/.claude/skills/ --public${C.reset} # Sync as public assets
1273
+ ${C.cyan}tokrepo sync . -y${C.reset} # Sync current dir, skip confirm
1274
+ ${C.cyan}tokrepo status${C.reset} # Show what would change
1275
+
1276
+ Sync scans a directory, detects new/modified assets, and pushes only
1277
+ what changed. Each subdirectory becomes one asset. Loose files become
1278
+ individual assets. Like ${C.bold}git push${C.reset} for your AI assets.
1279
+
895
1280
  ${C.bold}EXAMPLES${C.reset}
896
1281
  tokrepo search "mcp server" # Find MCP configs
897
1282
  tokrepo install ca000374-f5d8-... # Install by UUID
@@ -919,6 +1304,8 @@ async function main() {
919
1304
  case 'login': await cmdLogin(); break;
920
1305
  case 'init': await cmdInit(); break;
921
1306
  case 'push': await cmdPush(); break;
1307
+ case 'sync': await cmdSync(); break;
1308
+ case 'status': case 'st': await cmdStatus(); break;
922
1309
  case 'pull': await cmdPull(); break;
923
1310
  case 'search': case 'find': await cmdSearch(); break;
924
1311
  case 'install': case 'i': await cmdInstall(); break;
@@ -927,11 +1314,16 @@ async function main() {
927
1314
  case 'delete': await cmdDelete(); break;
928
1315
  case 'tags': await cmdTags(); break;
929
1316
  case 'whoami': await cmdWhoami(); break;
1317
+ case '--version': case '-v': case 'version':
1318
+ log(`tokrepo ${CLI_VERSION}`); break;
930
1319
  case 'help': case '--help': case '-h': case undefined:
931
1320
  showHelp(); break;
932
1321
  default:
933
1322
  error(`Unknown command: ${command}. Run: tokrepo help`);
934
1323
  }
1324
+
1325
+ // Non-blocking update check after command completes
1326
+ checkForUpdate();
935
1327
  }
936
1328
 
937
1329
  main().catch((e) => { error(e.message); });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tokrepo",
3
- "version": "2.0.0",
4
- "description": "AI assets for humans and agents — search, install, push. Like GitHub, for AI experience.",
3
+ "version": "3.0.0",
4
+ "description": "AI assets for humans and agents — sync, search, install, push. Like git push for your AI skills.",
5
5
  "bin": {
6
6
  "tokrepo": "bin/tokrepo.js"
7
7
  },