@ahmednawaz/crank 0.1.2

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.
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@crank/companion",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "main": "server.js",
6
+ "bin": {
7
+ "crank-companion": "bin/crank.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node server.js"
11
+ },
12
+ "dependencies": {
13
+ "@cursor/sdk": "^1.0.23",
14
+ "cors": "^2.8.5",
15
+ "express": "^4.21.2",
16
+ "ws": "^8.18.0"
17
+ }
18
+ }
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Persist an applied variant to source via Vizpatch text-writer (SAVE_TEXT_CHANGE).
5
+ * Skips locked/dynamic nodes with an explicit reason — never silent.
6
+ */
7
+
8
+ const { saveTextChange, pathHintsFromUrl } = require('./text-dispatch');
9
+
10
+ function roleLabel(role) {
11
+ const r = String(role || 'text').toLowerCase();
12
+ if (r === 'heading') return 'Heading';
13
+ if (r === 'action' || r === 'button') return 'CTA';
14
+ if (r === 'label') return 'Label';
15
+ return 'Body';
16
+ }
17
+
18
+ /**
19
+ * @param {object} session - session AFTER store.applyVariant (nodes already updated)
20
+ * @param {object} variant
21
+ * @param {string} projectRoot
22
+ * @param {object} [options]
23
+ * @param {object[]} [options.preApplyNodes] - session.nodes snapshot before apply
24
+ */
25
+ function persistAppliedVariant(session, variant, projectRoot, options = {}) {
26
+ const preNodes = Array.isArray(options.preApplyNodes)
27
+ ? options.preApplyNodes
28
+ : session?.nodes || [];
29
+ const preByPath = new Map(preNodes.map((n) => [String(n.path), n]));
30
+ const variantNodes = Array.isArray(variant?.nodes) ? variant.nodes : [];
31
+
32
+ const results = [];
33
+ let saved = 0;
34
+ let skipped = 0;
35
+ let failed = 0;
36
+
37
+ for (const vn of variantNodes) {
38
+ const path = String(vn.path);
39
+ const pre =
40
+ preByPath.get(path) ||
41
+ (vn.id
42
+ ? preNodes.find((n) => n && String(n.id) === String(vn.id))
43
+ : null) ||
44
+ {};
45
+ const label = roleLabel(vn.role || pre.role);
46
+ const newText = String(vn.text != null ? vn.text : '');
47
+ const oldText = String(
48
+ pre.originalText != null
49
+ ? pre.originalText
50
+ : pre.previousText != null
51
+ ? pre.previousText
52
+ : pre.text != null
53
+ ? pre.text
54
+ : ''
55
+ );
56
+
57
+ if (oldText === newText) {
58
+ results.push({
59
+ path,
60
+ role: vn.role || pre.role,
61
+ label,
62
+ status: 'unchanged',
63
+ message: 'No text change'
64
+ });
65
+ skipped += 1;
66
+ continue;
67
+ }
68
+
69
+ const editable = pre.editable !== false && vn.editable !== false;
70
+ const sourceType =
71
+ (pre.textSource && pre.textSource.type) ||
72
+ (vn.textSource && vn.textSource.type) ||
73
+ '';
74
+
75
+ if (!editable) {
76
+ results.push({
77
+ path,
78
+ role: vn.role || pre.role,
79
+ label,
80
+ status: 'skipped',
81
+ reason: 'locked',
82
+ message:
83
+ sourceType === 'dynamic-data'
84
+ ? 'Dynamic/bound data — cannot write hardcoded source'
85
+ : sourceType === 'bound-unknown'
86
+ ? 'Bound text — cannot save to source'
87
+ : 'Node is not editable for source save'
88
+ });
89
+ skipped += 1;
90
+ continue;
91
+ }
92
+
93
+ const selectorText =
94
+ vn.selector || pre.selector || session.selector || '';
95
+ if (!selectorText) {
96
+ results.push({
97
+ path,
98
+ role: vn.role || pre.role,
99
+ label,
100
+ status: 'failed',
101
+ message: 'No CSS selector available for text save'
102
+ });
103
+ failed += 1;
104
+ continue;
105
+ }
106
+
107
+ if (!oldText) {
108
+ results.push({
109
+ path,
110
+ role: vn.role || pre.role,
111
+ label,
112
+ status: 'failed',
113
+ message:
114
+ 'Missing originalText/oldValue for source resolve — re-select the element'
115
+ });
116
+ failed += 1;
117
+ continue;
118
+ }
119
+
120
+ const pageUrl = session.pageUrl || session.meta?.pageUrl || '';
121
+ const pathHints = []
122
+ .concat(Array.isArray(session.meta?.pathHints) ? session.meta.pathHints : [])
123
+ .concat(pathHintsFromUrl(pageUrl));
124
+
125
+ const saveResult = saveTextChange(
126
+ {
127
+ selectorText,
128
+ value: newText,
129
+ oldValue: oldText,
130
+ sourceUrl: pageUrl,
131
+ origin: session.origin || '',
132
+ pageUrl,
133
+ pathHints,
134
+ file: session.meta?.file || pre.file || undefined,
135
+ componentName:
136
+ session.meta?.componentName ||
137
+ pre.componentName ||
138
+ vn.componentName ||
139
+ undefined
140
+ },
141
+ projectRoot
142
+ );
143
+
144
+ if (saveResult.success) {
145
+ saved += 1;
146
+ results.push({
147
+ path,
148
+ role: vn.role || pre.role,
149
+ label,
150
+ status: 'saved',
151
+ file: saveResult.file,
152
+ message: `Saved to ${saveResult.file || 'source'}`
153
+ });
154
+ } else {
155
+ failed += 1;
156
+ results.push({
157
+ path,
158
+ role: vn.role || pre.role,
159
+ label,
160
+ status: 'failed',
161
+ file: saveResult.file,
162
+ message: saveResult.error || 'Save failed',
163
+ attemptedFiles: saveResult.attemptedFiles
164
+ });
165
+ }
166
+ }
167
+
168
+ const summaryParts = [];
169
+ if (saved) summaryParts.push(`saved ${saved}`);
170
+ if (failed) summaryParts.push(`failed ${failed}`);
171
+ if (skipped) summaryParts.push(`skipped ${skipped}`);
172
+
173
+ return {
174
+ ok: failed === 0,
175
+ persisted: saved > 0,
176
+ saved,
177
+ failed,
178
+ skipped,
179
+ results,
180
+ summary:
181
+ summaryParts.length > 0
182
+ ? `Source: ${summaryParts.join(', ')}`
183
+ : 'Source: nothing to write'
184
+ };
185
+ }
186
+
187
+ module.exports = {
188
+ persistAppliedVariant
189
+ };
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * In-memory selection + variant session store.
5
+ * Browser bookmarklet writes here; MCP tools and Glean client read/write here.
6
+ */
7
+
8
+ const crypto = require('crypto');
9
+ const { assignUnitLabels } = require('../lib/copy-labels');
10
+
11
+ /** @type {Map<string, object>} */
12
+ const sessions = new Map();
13
+
14
+ let activeSessionId = null;
15
+
16
+ function createSessionId() {
17
+ return crypto.randomBytes(8).toString('hex');
18
+ }
19
+
20
+ /**
21
+ * @param {object} payload
22
+ * @returns {object}
23
+ */
24
+ function normalizeNodes(nodes) {
25
+ return assignUnitLabels(
26
+ (Array.isArray(nodes) ? nodes : []).map((n, i) => {
27
+ const text = n && n.text != null ? String(n.text) : '';
28
+ const path = n && n.path != null ? String(n.path) : String(i);
29
+ return {
30
+ ...n,
31
+ id: (n && n.id) || `copy-${path}`,
32
+ path,
33
+ text,
34
+ previousText: n.previousText != null ? String(n.previousText) : text,
35
+ // Freeze source baseline for SAVE_TEXT_CHANGE oldValue across applies.
36
+ originalText:
37
+ n.originalText != null ? String(n.originalText) : text
38
+ };
39
+ })
40
+ );
41
+ }
42
+
43
+ function setSelection(payload) {
44
+ const id = createSessionId();
45
+ const nodes = normalizeNodes(payload.nodes);
46
+ const session = {
47
+ id,
48
+ createdAt: new Date().toISOString(),
49
+ updatedAt: new Date().toISOString(),
50
+ origin: payload.origin || '',
51
+ pageUrl: payload.pageUrl || '',
52
+ selector: payload.selector || '',
53
+ tagName: payload.tagName || '',
54
+ nodes,
55
+ combinedText: payload.combinedText || nodes.map((n) => n.text).join('\n'),
56
+ variants: [],
57
+ appliedVariantId: null,
58
+ meta: payload.meta || {}
59
+ };
60
+ sessions.set(id, session);
61
+ activeSessionId = id;
62
+ return session;
63
+ }
64
+
65
+ function getActiveSession() {
66
+ if (!activeSessionId) {
67
+ return null;
68
+ }
69
+ return sessions.get(activeSessionId) || null;
70
+ }
71
+
72
+ function getSession(id) {
73
+ if (!id) {
74
+ return getActiveSession();
75
+ }
76
+ return sessions.get(id) || null;
77
+ }
78
+
79
+ function listSessions() {
80
+ return Array.from(sessions.values()).sort((a, b) =>
81
+ String(b.updatedAt).localeCompare(String(a.updatedAt))
82
+ );
83
+ }
84
+
85
+ /**
86
+ * @param {string} sessionId
87
+ * @param {object[]} variants
88
+ */
89
+ function setVariants(sessionId, variants) {
90
+ const session = getSession(sessionId);
91
+ if (!session) {
92
+ throw new Error(`Unknown session: ${sessionId}`);
93
+ }
94
+ session.variants = Array.isArray(variants) ? variants : [];
95
+ session.updatedAt = new Date().toISOString();
96
+ sessions.set(session.id, session);
97
+ activeSessionId = session.id;
98
+ return session;
99
+ }
100
+
101
+ /**
102
+ * Apply a variant's node map onto the stored selection (source of truth for MCP).
103
+ * Live DOM apply is pushed to connected browsers via WebSocket by the server.
104
+ */
105
+ function applyVariant(sessionId, variantId) {
106
+ const session = getSession(sessionId);
107
+ if (!session) {
108
+ throw new Error(`Unknown session: ${sessionId}`);
109
+ }
110
+ const variant = (session.variants || []).find((v) => v.id === variantId);
111
+ if (!variant) {
112
+ throw new Error(`Unknown variant: ${variantId}`);
113
+ }
114
+
115
+ const byPath = new Map(
116
+ (variant.nodes || []).map((n) => [String(n.path), n])
117
+ );
118
+
119
+ session.nodes = (session.nodes || []).map((node) => {
120
+ const next =
121
+ byPath.get(String(node.path)) ||
122
+ (node.id
123
+ ? (variant.nodes || []).find((n) => String(n.id) === String(node.id))
124
+ : null);
125
+ if (!next) {
126
+ return node;
127
+ }
128
+ return {
129
+ ...node,
130
+ id: node.id || next.id || `copy-${node.path}`,
131
+ text: next.text,
132
+ previousText: node.text,
133
+ originalText:
134
+ node.originalText != null ? node.originalText : node.text
135
+ };
136
+ });
137
+ session.combinedText = session.nodes.map((n) => n.text).join('\n');
138
+ session.appliedVariantId = variantId;
139
+ session.updatedAt = new Date().toISOString();
140
+ sessions.set(session.id, session);
141
+ activeSessionId = session.id;
142
+ return { session, variant };
143
+ }
144
+
145
+ function clearSessions() {
146
+ sessions.clear();
147
+ activeSessionId = null;
148
+ }
149
+
150
+ function setGleanProgress(sessionId, progress) {
151
+ const session = getSession(sessionId);
152
+ if (!session) return null;
153
+ session.gleanProgress = progress || null;
154
+ session.gleanProgressAt = new Date().toISOString();
155
+ sessions.set(session.id, session);
156
+ return session.gleanProgress;
157
+ }
158
+
159
+ function getGleanProgress(sessionId) {
160
+ const session = getSession(sessionId);
161
+ if (!session) return null;
162
+ return session.gleanProgress || null;
163
+ }
164
+
165
+ module.exports = {
166
+ setSelection,
167
+ getActiveSession,
168
+ getSession,
169
+ listSessions,
170
+ setVariants,
171
+ applyVariant,
172
+ clearSessions,
173
+ setGleanProgress,
174
+ getGleanProgress
175
+ };