@remits/remits-cli 0.1.141 → 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.
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.141",
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,
@@ -98,6 +98,12 @@ reference named after it.
98
98
  not run JavaScript, Alpine, CSS/assets, redirects, cookies/session handling, uploads, iframes,
99
99
  shadow-DOM placement, or websocket behavior. (`development-loop.md`,
100
100
  `docs/front-stage/components/embeddable-components.md`, `docs/front-stage/features/web-browser-automation.md`)
101
+ - **Browser automation cleanup is part of the task.** When you finish any browser automation you
102
+ started, close Playwright pages/contexts/browsers, stop any `playwright-cli` daemon/session you
103
+ started, remove temporary Playwright session/profile state when safe, and verify no task-owned
104
+ Playwright or headless Chrome processes remain. Clean up only processes you own for this task; never
105
+ blindly kill normal Chrome or another agent's browser automation. (`development-loop.md`,
106
+ `docs/front-stage/features/web-browser-automation.md`)
101
107
  - **Land only from trunk or a real variant branch.** A per-agent/feature branch (`components status` says
102
108
  `FEATURE BRANCH … [subscription-fallback]`) is safe to stage and run from — it resolves the same world
103
109
  as its target — but `components commit`/`sync` refuse it. Merge into the branch it resolves and land
@@ -164,13 +164,20 @@ something or expose a JSON/action payload, but it is not evidence that the user-
164
164
  JavaScript, Alpine initialization, assets, cookies/session state, redirects, file controls, iframe/embed
165
165
  placement, and websocket updates all require a browser.
166
166
 
167
+ After any browser automation task, clean up what you started: close Playwright pages, browser contexts
168
+ and browser objects; stop any `playwright-cli` daemon/session created for the task; remove temporary
169
+ Playwright session/profile state when safe; and verify task-owned Playwright/headless Chrome processes
170
+ are gone. Do not use broad process kills that might close a human browser or another agent's automation.
171
+
167
172
  **Never skip verification.** "Just do it" and "that's fine, commit it" are not evidence. If you genuinely
168
173
  cannot verify — no Test component, no relevant embeddable — say what you would need and ask how the user
169
174
  wants to proceed rather than reporting the work as done.
170
175
 
171
176
  > The *design* rule that pairs with this — never solve interpretive problems with regex cascades, keyword
172
177
  > lists, or layout-specific branching when the platform's AI surface is the right tool — is in the account
173
- > repo's `CLAUDE.md` and, in depth, in `features/ai-strategy.md`.
178
+ > repo's `CLAUDE.md` and, in depth, in `features/ai-strategy.md`. For an AI workflow, verification also means
179
+ > reading the run as the model received it - every turn, passing runs too - and measuring on more than one
180
+ > input: `development-core.md` -> *AI Workflows: Design Through Every Lens* and `features/ai-workflow-design.md`.
174
181
 
175
182
  ### The Development Fast Loop
176
183