@remits/remits-cli 0.1.143 → 0.1.145

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/index.js +345 -3
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -14,8 +14,8 @@
14
14
  - L7332 Branches, Promotion, Commit, And Test Runs
15
15
  - L9421 Tokens, Tools, Verification, And Config
16
16
  - L10848 Service Dashboard And WebSocket Listener
17
- - L12971 Agent And Ticket Workflows
18
- - L16566 Help, Auto Update, And Command Dispatch
17
+ - L13313 Agent And Ticket Workflows
18
+ - L16908 Help, Auto Update, And Command Dispatch
19
19
  */
20
20
 
21
21
  /*
@@ -12615,6 +12615,327 @@ function publishMissedMessageRequest(client, baseUrl, userId) {
12615
12615
  });
12616
12616
  }
12617
12617
 
12618
+ function cliSessionsForUser(baseUrl, userId, accountId = null) {
12619
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl || DEFAULT_BASE_URL);
12620
+ return listSessionsFromStore(readAllSessions()).filter((session) => {
12621
+ const sessionUserId = session && session.user && session.user.id ? String(session.user.id) : '';
12622
+ if (!session || !session.token || sessionUserId !== String(userId)) {
12623
+ return false;
12624
+ }
12625
+ if (normalizeBaseUrl(session.baseUrl || DEFAULT_BASE_URL) !== normalizedBaseUrl) {
12626
+ return false;
12627
+ }
12628
+ if (accountId != null && Number(session.accountId) !== Number(accountId)) {
12629
+ return false;
12630
+ }
12631
+ return true;
12632
+ });
12633
+ }
12634
+
12635
+ function cliSocketSessionForUser(baseUrl, userId, accountId = null) {
12636
+ return cliSessionsForUser(baseUrl, userId, accountId)[0] || null;
12637
+ }
12638
+
12639
+ function cliTokenFingerprint(token) {
12640
+ if (!token) return '';
12641
+ return crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 16);
12642
+ }
12643
+
12644
+ function cliCloneSigningText(payload) {
12645
+ return [
12646
+ payload.messageId,
12647
+ payload.userId,
12648
+ payload.accountId,
12649
+ payload.repoName,
12650
+ payload.gitUrl,
12651
+ payload.httpsUrl,
12652
+ payload.branch,
12653
+ payload.tokenFingerprint
12654
+ ].map((value) => value == null ? '' : String(value)).join('\n');
12655
+ }
12656
+
12657
+ function signCliCloneRequest(token, payload) {
12658
+ return crypto.createHmac('sha256', String(token)).update(cliCloneSigningText(payload)).digest('hex');
12659
+ }
12660
+
12661
+ function signaturesMatch(expected, actual) {
12662
+ const a = Buffer.from(String(expected || ''), 'hex');
12663
+ const b = Buffer.from(String(actual || ''), 'hex');
12664
+ return a.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b);
12665
+ }
12666
+
12667
+ function validateRepoNameForClone(repoName) {
12668
+ const value = String(repoName || '').trim();
12669
+ if (!value || value === '.' || value === '..' || !/^[A-Za-z0-9._-]+$/.test(value)) {
12670
+ throw new Error('Unsafe repository name in clone request');
12671
+ }
12672
+ return value;
12673
+ }
12674
+
12675
+ function validateGithubCloneUrl(url, repoName) {
12676
+ const value = String(url || '').trim();
12677
+ const escapedRepo = repoName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12678
+ const ssh = new RegExp('^git@github\\.com:[A-Za-z0-9_.-]+/' + escapedRepo + '\\.git$');
12679
+ const https = new RegExp('^https://github\\.com/[A-Za-z0-9_.-]+/' + escapedRepo + '\\.git$');
12680
+ if (!ssh.test(value) && !https.test(value)) {
12681
+ throw new Error('Unexpected GitHub clone URL in clone request');
12682
+ }
12683
+ return value;
12684
+ }
12685
+
12686
+ function authenticatedCloneSession(baseUrl, data, payload) {
12687
+ const accountId = Number(payload.accountId);
12688
+ const requestedUserId = String(payload.userId || data.userId || '');
12689
+ const messageId = String(payload.messageId || payload.uuid || data.messageId || '');
12690
+ const expectedFingerprint = String(payload.tokenFingerprint || '').trim();
12691
+ if (!requestedUserId || !messageId || !expectedFingerprint || !Number.isFinite(accountId) || accountId <= 0) {
12692
+ throw new Error('Malformed clone request');
12693
+ }
12694
+
12695
+ const signingPayload = {
12696
+ messageId,
12697
+ userId: requestedUserId,
12698
+ accountId,
12699
+ repoName: payload.repoName,
12700
+ gitUrl: payload.gitUrl,
12701
+ httpsUrl: payload.httpsUrl,
12702
+ branch: payload.branch || 'main',
12703
+ tokenFingerprint: expectedFingerprint
12704
+ };
12705
+
12706
+ const candidateSessions = cliSessionsForUser(baseUrl, requestedUserId)
12707
+ .filter((session) => cliTokenFingerprint(session.token) === expectedFingerprint);
12708
+
12709
+ for (const session of candidateSessions) {
12710
+ const expected = signCliCloneRequest(session.token, signingPayload);
12711
+ if (signaturesMatch(expected, payload.cloneAuth)) {
12712
+ return session;
12713
+ }
12714
+ }
12715
+
12716
+ throw new Error('Unauthenticated clone request');
12717
+ }
12718
+
12719
+ function publishCliPresence(client, baseUrl, userIds = []) {
12720
+ if (!client || !client.connected) {
12721
+ return;
12722
+ }
12723
+ const validUserIds = Array.from(new Set((Array.isArray(userIds) ? userIds : [userIds]).filter(Boolean)));
12724
+ if (validUserIds.length === 0) {
12725
+ return;
12726
+ }
12727
+ for (const userId of validUserIds) {
12728
+ const session = cliSocketSessionForUser(baseUrl, userId);
12729
+ if (!session || !session.token) {
12730
+ console.warn('[listener] Skipping CLI presence for user ' + userId + ': no authenticated local session token');
12731
+ continue;
12732
+ }
12733
+ const repos = listAccountRepoEntries().map((entry) => ({
12734
+ accountId: Number(entry.accountId),
12735
+ name: entry.name,
12736
+ directory: entry.directory,
12737
+ branch: currentBranch(entry.directory) || 'main'
12738
+ }));
12739
+ const payload = {
12740
+ userId: String(userId),
12741
+ cliVersion: readCliVersion(),
12742
+ hostname: os.hostname(),
12743
+ platform: process.platform,
12744
+ scannedAt: new Date().toISOString(),
12745
+ repos
12746
+ };
12747
+ payload.token = session.token;
12748
+ try {
12749
+ client.publish({
12750
+ destination: '/app/cli-presence',
12751
+ body: JSON.stringify(payload)
12752
+ });
12753
+ console.log('[listener] Emitted CLI presence (' + repos.length + ' account repos for user ' + userId + ')');
12754
+ } catch (err) {
12755
+ console.error('[listener] Failed to emit CLI presence:', err.message);
12756
+ }
12757
+ }
12758
+ }
12759
+
12760
+ function resolveAccountRepoCloneTarget(repoName) {
12761
+ if (process.env.REMITS_ACCOUNT_REPOS_DIR) {
12762
+ return path.join(path.resolve(process.env.REMITS_ACCOUNT_REPOS_DIR), repoName);
12763
+ }
12764
+ const entries = listAccountRepoEntries();
12765
+ const parentCounts = {};
12766
+ for (const entry of entries) {
12767
+ if (entry.directory && fs.existsSync(entry.directory)) {
12768
+ const parent = path.dirname(entry.directory);
12769
+ parentCounts[parent] = (parentCounts[parent] || 0) + 1;
12770
+ }
12771
+ }
12772
+ const sortedParents = Object.entries(parentCounts).sort((a, b) => b[1] - a[1]);
12773
+ if (sortedParents.length > 0) {
12774
+ return path.join(sortedParents[0][0], repoName);
12775
+ }
12776
+ const homeProjects = path.join(os.homedir(), 'Documents', 'Projects');
12777
+ if (fs.existsSync(homeProjects)) {
12778
+ return path.join(homeProjects, 'remits_accounts', repoName);
12779
+ }
12780
+ return path.join(os.homedir(), 'remits_accounts', repoName);
12781
+ }
12782
+
12783
+ async function handleCliCloneRequest(client, baseUrl, topic, accountIds, userIds, data) {
12784
+ const payload = data.payload || data;
12785
+ let cloneSession = null;
12786
+ let accountId = Number(payload.accountId);
12787
+ let repoName = String(payload.repoName || '');
12788
+ let gitUrl = String(payload.gitUrl || '');
12789
+ let httpsUrl = String(payload.httpsUrl || '');
12790
+ const branch = String(payload.branch || 'main');
12791
+ const messageId = String(payload.messageId || payload.uuid || data.messageId || '');
12792
+ let targetUserId = String(payload.userId || data.userId || '');
12793
+
12794
+ console.log('[cli_clone_request] Received clone request for account', accountId, repoName);
12795
+ appendGlobalActivityLog('repo', 'clone_request.received', {
12796
+ baseUrl,
12797
+ accountId,
12798
+ repoName,
12799
+ gitUrl,
12800
+ messageId
12801
+ });
12802
+
12803
+ let targetDir = null;
12804
+ let cloneSuccess = false;
12805
+ let cloneError = null;
12806
+
12807
+ try {
12808
+ cloneSession = authenticatedCloneSession(baseUrl, data, payload);
12809
+ targetUserId = cloneSession && cloneSession.user && cloneSession.user.id ? String(cloneSession.user.id) : targetUserId;
12810
+ repoName = validateRepoNameForClone(payload.repoName);
12811
+ gitUrl = validateGithubCloneUrl(payload.gitUrl, repoName);
12812
+ httpsUrl = validateGithubCloneUrl(payload.httpsUrl, repoName);
12813
+ targetDir = resolveAccountRepoCloneTarget(repoName);
12814
+
12815
+ if (fs.existsSync(targetDir) && fs.existsSync(path.join(targetDir, '.git'))) {
12816
+ console.log('[cli_clone_request] Repo already exists at', targetDir);
12817
+ cloneSuccess = true;
12818
+ } else {
12819
+ ensureDir(path.dirname(targetDir));
12820
+ console.log('[cli_clone_request] Cloning into', targetDir);
12821
+ try {
12822
+ execSync('git clone ' + shellQuote(gitUrl) + ' ' + shellQuote(targetDir), { stdio: 'pipe' });
12823
+ cloneSuccess = true;
12824
+ } catch (sshErr) {
12825
+ console.warn('[cli_clone_request] SSH clone failed, attempting HTTPS fallback:', sshErr.message);
12826
+ if (httpsUrl) {
12827
+ try {
12828
+ execSync('git clone ' + shellQuote(httpsUrl) + ' ' + shellQuote(targetDir), { stdio: 'pipe' });
12829
+ cloneSuccess = true;
12830
+ } catch (httpsErr) {
12831
+ cloneError = httpsErr;
12832
+ }
12833
+ } else {
12834
+ cloneError = sshErr;
12835
+ }
12836
+ }
12837
+ }
12838
+
12839
+ if (!cloneSuccess) {
12840
+ throw cloneError || new Error('Failed to clone repository ' + repoName);
12841
+ }
12842
+
12843
+ // 1. Update index in ~/.remits-cli/account-repos.json
12844
+ updateAccountRepoIndex(targetDir);
12845
+
12846
+ // 2. Update discovery summary in ~/.remits-cli/service-state.json
12847
+ recordDiscoverySummary(discoverAccountRepos());
12848
+
12849
+ // 3. Download front-stage guides & agent docs into targetDir
12850
+ const sessions = listSessionsFromStore(readAllSessions());
12851
+ const session = guideSessionForRepo({ accountId }, sessions) || { baseUrl };
12852
+ try {
12853
+ await downloadAndSyncGuides(targetDir, { session, baseUrl, accountId });
12854
+ } catch (gErr) {
12855
+ console.warn('[cli_clone_request] Guide sync failed:', gErr.message);
12856
+ }
12857
+
12858
+ // 4. Install / refresh agent skills for Claude, Codex, Gemini
12859
+ try {
12860
+ syncInstalledSkills('all');
12861
+ } catch (_) {}
12862
+
12863
+ // 5. Append to global activity log
12864
+ appendGlobalActivityLog('repo', 'clone.success', {
12865
+ accountId,
12866
+ repoName,
12867
+ directory: targetDir
12868
+ });
12869
+
12870
+ console.log('[cli_clone_request] Successfully cloned and initialized repo at', targetDir);
12871
+
12872
+ // 6. Publish updated presence so server and browser know immediately
12873
+ publishCliPresence(client, baseUrl, userIds);
12874
+
12875
+ // 7. Publish completion notification to user
12876
+ const completionPayload = {
12877
+ messageId,
12878
+ uuid: messageId,
12879
+ type: 'cli_clone_completed',
12880
+ accountId,
12881
+ repoName,
12882
+ directory: targetDir,
12883
+ branch,
12884
+ success: true
12885
+ };
12886
+
12887
+ try {
12888
+ client.publish({
12889
+ destination: '/app/cli-event',
12890
+ body: JSON.stringify({
12891
+ ...completionPayload,
12892
+ userId: targetUserId,
12893
+ token: cloneSession.token
12894
+ })
12895
+ });
12896
+ client.publish({
12897
+ destination: '/app/reply',
12898
+ body: JSON.stringify(completionPayload)
12899
+ });
12900
+ } catch (_) {}
12901
+
12902
+ } catch (err) {
12903
+ console.error('[cli_clone_request] Error cloning repo:', err.message);
12904
+ appendGlobalActivityLog('repo', 'clone.error', {
12905
+ accountId,
12906
+ repoName,
12907
+ error: err.message
12908
+ }, 'error');
12909
+
12910
+ const errorPayload = {
12911
+ messageId,
12912
+ uuid: messageId,
12913
+ type: 'cli_clone_failed',
12914
+ accountId,
12915
+ repoName,
12916
+ success: false,
12917
+ error: err.message
12918
+ };
12919
+
12920
+ try {
12921
+ if (cloneSession && cloneSession.token) {
12922
+ client.publish({
12923
+ destination: '/app/cli-event',
12924
+ body: JSON.stringify({
12925
+ ...errorPayload,
12926
+ userId: targetUserId,
12927
+ token: cloneSession.token
12928
+ })
12929
+ });
12930
+ }
12931
+ client.publish({
12932
+ destination: '/app/reply',
12933
+ body: JSON.stringify(errorPayload)
12934
+ });
12935
+ } catch (_) {}
12936
+ }
12937
+ }
12938
+
12618
12939
  function acknowledgeWebsocketMessage(client, messageId, userIds = []) {
12619
12940
  if (!messageId) {
12620
12941
  return;
@@ -12697,6 +13018,8 @@ function processInboundWebsocketMessage(client, baseUrl, topic, accountIds, user
12697
13018
  });
12698
13019
  supportTicketSnapshotCache = { fetchedAtMs: 0, value: null };
12699
13020
  agentSnapshotCache = { fetchedAtMs: 0, value: null };
13021
+ } else if (data.type === 'cli_clone_request') {
13022
+ handleCliCloneRequest(client, baseUrl, topic, accountIds, userIds, data);
12700
13023
  }
12701
13024
 
12702
13025
  updateRuntimeServiceState();
@@ -12710,7 +13033,8 @@ function createManagedWebsocketClient(baseUrl, urlSessions) {
12710
13033
  client: null,
12711
13034
  lastConnectAttemptAt: null,
12712
13035
  lastConnectedAt: null,
12713
- lastDisconnectedAt: null
13036
+ lastDisconnectedAt: null,
13037
+ lastPresenceAt: null
12714
13038
  };
12715
13039
 
12716
13040
  ensureRuntimeWebsocketEntry(normalizedBaseUrl);
@@ -12814,6 +13138,9 @@ function createManagedWebsocketClient(baseUrl, urlSessions) {
12814
13138
  for (const userId of userIds) {
12815
13139
  publishMissedMessageRequest(client, normalizedBaseUrl, userId);
12816
13140
  }
13141
+
13142
+ publishCliPresence(client, normalizedBaseUrl, userIds);
13143
+ managedClient.lastPresenceAt = Date.now();
12817
13144
  };
12818
13145
 
12819
13146
  client.onDisconnect = () => {
@@ -12926,6 +13253,21 @@ async function runManagedWebsocketWatchdog() {
12926
13253
  const state = ensureRuntimeWebsocketEntry(managedClient.baseUrl);
12927
13254
  const socket = managedClient.client.webSocket;
12928
13255
  const readyState = socket && typeof socket.readyState === 'number' ? socket.readyState : null;
13256
+
13257
+ if (state.status === 'connected' && managedClient.client.connected) {
13258
+ if (!managedClient.lastPresenceAt || (now - managedClient.lastPresenceAt) >= 30000) {
13259
+ managedClient.lastPresenceAt = now;
13260
+ const userIds = [];
13261
+ for (const session of (managedClient.urlSessions || [])) {
13262
+ const userId = session && session.user && session.user.id ? String(session.user.id) : null;
13263
+ if (userId && !userIds.includes(userId)) {
13264
+ userIds.push(userId);
13265
+ }
13266
+ }
13267
+ publishCliPresence(managedClient.client, managedClient.baseUrl, userIds);
13268
+ }
13269
+ }
13270
+
12929
13271
  const disconnectedAtMs = state.disconnectedAt ? Date.parse(state.disconnectedAt) : 0;
12930
13272
  const lastConnectAttemptAge = managedClient.lastConnectAttemptAt ? (now - managedClient.lastConnectAttemptAt) : 0;
12931
13273
  const connectedReadyMismatch = state.status === 'connected' && readyState != null && readyState !== WebSocket.OPEN;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remits/remits-cli",
3
- "version": "0.1.143",
3
+ "version": "0.1.145",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,