@symbo.ls/cli 2.33.12 → 2.33.13

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/collab.js ADDED
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs'
4
+ import path from 'path'
5
+ import chalk from 'chalk'
6
+ import { program } from './program.js'
7
+ import { CredentialManager } from '../helpers/credentialManager.js'
8
+ import { loadSymbolsConfig } from '../helpers/symbolsConfig.js'
9
+ import { loadCliConfig, readLock, writeLock, getConfigPaths } from '../helpers/config.js'
10
+ import { stringifyFunctionsForTransport } from '../helpers/transportUtils.js'
11
+ import { getCurrentProjectData } from '../helpers/apiUtils.js'
12
+ import { computeCoarseChanges, computeOrdersForTuples, preprocessChanges } from '../helpers/changesUtils.js'
13
+ import { createFs } from './fs.js'
14
+
15
+ // Lazy import socket.io-client and chokidar to avoid adding cost for non-collab users
16
+ async function importDeps() {
17
+ const [{ default: io }, { default: chokidar }] = await Promise.all([
18
+ import('socket.io-client'),
19
+ import('chokidar')
20
+ ])
21
+ return { io, chokidar }
22
+ }
23
+
24
+ function debounce(fn, wait) {
25
+ let t = null
26
+ const debounced = (...args) => {
27
+ clearTimeout(t)
28
+ t = setTimeout(() => fn(...args), wait)
29
+ }
30
+ debounced.cancel = () => {
31
+ clearTimeout(t)
32
+ t = null
33
+ }
34
+ return debounced
35
+ }
36
+
37
+ export async function startCollab(options) {
38
+ const credManager = new CredentialManager()
39
+ const authToken = credManager.ensureAuthToken()
40
+ if (!authToken) {
41
+ console.log(chalk.yellow('\nAuthentication required. Please run: smbls login\n'))
42
+ process.exit(1)
43
+ }
44
+
45
+ const symbolsConfig = await loadSymbolsConfig()
46
+ const cliConfig = loadCliConfig()
47
+ const lock = readLock()
48
+ const branch = options.branch || cliConfig.branch || symbolsConfig.branch || 'main'
49
+ const appKey = cliConfig.projectKey || symbolsConfig.key
50
+
51
+ if (!appKey) {
52
+ console.log(chalk.red('Missing project key. Add it to symbols.json or .symbols/config.json'))
53
+ process.exit(1)
54
+ }
55
+
56
+ const { io, chokidar } = await importDeps()
57
+ const baseUrl = cliConfig.apiBaseUrl
58
+
59
+ console.log(chalk.dim(`\nConnecting to realtime collab on ${baseUrl} ...`))
60
+
61
+ // Maintain in-memory base state and a guard to suppress local echoes
62
+ let currentBase = {}
63
+ let suppressLocalChanges = false
64
+ let suppressUntil = 0
65
+ const suppressionWindowMs = Math.max(1500, (options.debounceMs || 200) * 8)
66
+
67
+ function isSuppressed() {
68
+ return suppressLocalChanges || Date.now() < suppressUntil
69
+ }
70
+
71
+ function setByPath(root, pathArr = [], value) {
72
+ if (!Array.isArray(pathArr) || !pathArr.length) return
73
+ let cur = root
74
+ for (let i = 0; i < pathArr.length - 1; i++) {
75
+ const seg = pathArr[i]
76
+ if (!cur[seg] || typeof cur[seg] !== 'object') cur[seg] = {}
77
+ cur = cur[seg]
78
+ }
79
+ cur[pathArr[pathArr.length - 1]] = value
80
+ }
81
+
82
+ function deleteByPath(root, pathArr = []) {
83
+ if (!Array.isArray(pathArr) || !pathArr.length) return
84
+ let cur = root
85
+ for (let i = 0; i < pathArr.length - 1; i++) {
86
+ const seg = pathArr[i]
87
+ if (!cur || typeof cur !== 'object') return
88
+ cur = cur[seg]
89
+ }
90
+ if (cur && typeof cur === 'object') {
91
+ delete cur[pathArr[pathArr.length - 1]]
92
+ }
93
+ }
94
+
95
+ function applyTuples(root, tuples = []) {
96
+ if (!root || typeof root !== 'object') return root
97
+ if (!Array.isArray(tuples)) return root
98
+ for (let i = 0; i < tuples.length; i++) {
99
+ const t = tuples[i]
100
+ if (!Array.isArray(t) || t.length < 2) continue
101
+ const [action, pathArr, value] = t
102
+ if (action === 'update' || action === 'set') {
103
+ setByPath(root, pathArr, value)
104
+ } else if (action === 'delete' || action === 'del') {
105
+ deleteByPath(root, pathArr)
106
+ }
107
+ }
108
+ return root
109
+ }
110
+
111
+ function applyOrders(root, orders = []) {
112
+ if (!root || typeof root !== 'object') return
113
+ if (!Array.isArray(orders)) return
114
+ for (let i = 0; i < orders.length; i++) {
115
+ const o = orders[i]
116
+ if (!o || !Array.isArray(o.path) || !Array.isArray(o.keys)) continue
117
+ // Ensure container exists
118
+ setByPath(root, o.path, (function ensure(obj) {
119
+ return obj && typeof obj === 'object' ? obj : {}
120
+ })(pathArrGet(root, o.path)))
121
+ // Now set __order on that container
122
+ const container = pathArrGet(root, o.path)
123
+ if (container && typeof container === 'object') {
124
+ container.__order = o.keys.slice()
125
+ }
126
+ }
127
+ }
128
+
129
+ function pathArrGet(root, pathArr = []) {
130
+ if (!Array.isArray(pathArr) || !pathArr.length) return root
131
+ let cur = root
132
+ for (let i = 0; i < pathArr.length; i++) {
133
+ if (!cur || typeof cur !== 'object') return undefined
134
+ cur = cur[pathArr[i]]
135
+ }
136
+ return cur
137
+ }
138
+
139
+ async function writeProjectAndFs(fullObj) {
140
+ const { projectPath } = getConfigPaths()
141
+ try {
142
+ await fs.promises.writeFile(projectPath, JSON.stringify(fullObj, null, 2))
143
+ } catch (_) {}
144
+ // Avoid echoing the changes we are about to materialize
145
+ suppressLocalChanges = true
146
+ // Cancel any pending local send
147
+ if (typeof sendLocalChanges.cancel === 'function') {
148
+ sendLocalChanges.cancel()
149
+ }
150
+ try {
151
+ await createFs(fullObj, path.join(process.cwd(), 'smbls'), { update: true, metadata: false })
152
+ } finally {
153
+ // Extend suppression window to allow file events to settle fully
154
+ suppressUntil = Date.now() + suppressionWindowMs
155
+ suppressLocalChanges = false
156
+ }
157
+ }
158
+
159
+ // Prime local base with latest snapshot
160
+ const prime = await getCurrentProjectData(
161
+ { projectKey: appKey, projectId: lock.projectId },
162
+ authToken,
163
+ { branch, includePending: true }
164
+ )
165
+ const initialData = prime?.data || {}
166
+ const etag = prime?.etag || null
167
+ writeLock({
168
+ etag,
169
+ version: initialData.version,
170
+ branch,
171
+ projectId: initialData?.projectInfo?.id || lock.projectId,
172
+ pulledAt: new Date().toISOString()
173
+ })
174
+
175
+ // Persist base snapshot
176
+ try {
177
+ const { projectPath } = getConfigPaths()
178
+ await fs.promises.mkdir(path.dirname(projectPath), { recursive: true })
179
+ await fs.promises.writeFile(projectPath, JSON.stringify(initialData, null, 2))
180
+ } catch (_) {}
181
+ currentBase = { ...(initialData || {}) }
182
+
183
+ // Connect to the collab namespace with required auth fields
184
+ const socket = io(`${baseUrl}`, {
185
+ path: '/collab-socket',
186
+ transports: ['websocket', 'polling'],
187
+ // Server expects these fields on handshake.auth
188
+ auth: {
189
+ token: authToken,
190
+ projectId: (lock.projectId || null),
191
+ projectKey: appKey,
192
+ key: appKey,
193
+ branch,
194
+ live: !!options.live,
195
+ clientType: 'cli'
196
+ },
197
+ // Some servers verify headers; keep Authorization for completeness
198
+ extraHeaders: { Authorization: `Bearer ${authToken}` },
199
+ reconnection: true,
200
+ reconnectionAttempts: Infinity,
201
+ reconnectionDelay: 1000,
202
+ timeout: 10000
203
+ })
204
+
205
+ socket.on('connect', () => {
206
+ console.log(chalk.green('Connected to collab server'))
207
+ })
208
+
209
+ socket.on('disconnect', (reason) => {
210
+ console.log(chalk.yellow(`Disconnected: ${reason}`))
211
+ })
212
+
213
+ socket.on('connect_error', (err) => {
214
+ console.log(chalk.red(`Connection error: ${err?.message || err}`))
215
+ })
216
+
217
+ // Receive snapshot and update local files + lock/base
218
+ socket.on('snapshot', async ({ version, branch: srvBranch, data, schema }) => {
219
+ const full = { ...(data || {}), schema: schema || {}, version, branch: srvBranch }
220
+ currentBase = full
221
+ await writeProjectAndFs(currentBase)
222
+ // Update lock’s version; ETag will change next pull
223
+ writeLock({ version, branch: srvBranch, pulledAt: new Date().toISOString() })
224
+ console.log(chalk.gray(`Snapshot applied. Version: ${chalk.cyan(version)}`))
225
+ })
226
+
227
+ // Receive granular ops from other clients/commits and apply to local files
228
+ socket.on('ops', async (payload) => {
229
+ // Apply incoming tuples directly to local base; do not re-emit builds triggered by this apply
230
+ try {
231
+ if (typeof sendLocalChanges.cancel === 'function') {
232
+ sendLocalChanges.cancel()
233
+ }
234
+ const tuples = Array.isArray(payload?.granularChanges) && payload.granularChanges.length
235
+ ? payload.granularChanges
236
+ : preprocessChanges(currentBase || {}, payload?.changes || []).granularChanges
237
+ if (!Array.isArray(tuples) || !tuples.length) return
238
+ applyTuples(currentBase, tuples)
239
+ if (Array.isArray(payload?.orders) && payload.orders.length) {
240
+ applyOrders(currentBase, payload.orders)
241
+ }
242
+ await writeProjectAndFs(currentBase)
243
+ writeLock({ pulledAt: new Date().toISOString() })
244
+ if (options.verbose) console.log(chalk.gray('Applied incoming ops to local workspace'))
245
+ } catch (e) {
246
+ if (options.verbose) console.error('Failed to apply incoming ops', e)
247
+ }
248
+ })
249
+
250
+ socket.on('commit', ({ version }) => {
251
+ writeLock({ version, pulledAt: new Date().toISOString() })
252
+ console.log(chalk.gray(`Server committed new version: ${chalk.cyan(version)}`))
253
+ })
254
+
255
+ // Watch local dist output and push coarse per-key changes
256
+ const distDir = path.join(process.cwd(), 'smbls')
257
+ const outputDir = path.join(distDir, 'dist')
258
+ const outputFile = path.join(outputDir, 'index.js')
259
+
260
+ // Build loader
261
+ async function loadLocalProject() {
262
+ try {
263
+ // Reuse build flow from push/sync
264
+ const { buildDirectory } = await import('../helpers/fileUtils.js')
265
+ const { loadModule } = await import('./require.js')
266
+ await buildDirectory(distDir, outputDir)
267
+ const loaded = await loadModule(outputFile, { silent: true, noCache: true })
268
+ return loaded
269
+ } catch (e) {
270
+ if (options.verbose) console.error('Build failed while watching:', e.message)
271
+ return null
272
+ }
273
+ }
274
+
275
+ const sendLocalChanges = debounce(async () => {
276
+ if (suppressLocalChanges) return
277
+ const local = await loadLocalProject()
278
+ if (!local) return
279
+ // Prepare safe, JSON-serialisable snapshots for diffing & transport
280
+ const base = currentBase || {}
281
+ const safeBase = stringifyFunctionsForTransport(base)
282
+ const safeLocal = stringifyFunctionsForTransport(local)
283
+ // Base snapshot is our last pulled .symbols/project.json
284
+ const changes = computeCoarseChanges(safeBase, safeLocal)
285
+ if (!changes.length) return
286
+ if (options.verbose) {
287
+ const byType = changes.reduce((acc, [t]) => ((acc[t] = (acc[t] || 0) + 1), acc), {})
288
+ console.log(chalk.gray(`Emitting local ops: ${JSON.stringify(byType)}`))
289
+ }
290
+ // Generate granular changes against base to ensure downstream consumers have fine ops
291
+ const { granularChanges } = preprocessChanges(safeBase, changes)
292
+ const orders = computeOrdersForTuples(safeLocal, granularChanges)
293
+ console.log(chalk.gray(`Emitting local ops: ${JSON.stringify({ changes, granularChanges, orders, branch })}`))
294
+ socket.emit('ops', {
295
+ changes,
296
+ granularChanges,
297
+ orders,
298
+ branch
299
+ })
300
+ }, options.debounceMs || 200)
301
+
302
+ const watcher = chokidar.watch(distDir, {
303
+ ignored: (p) => {
304
+ // Ignore hidden files, build output directory, and temporary files
305
+ if (/(^|[\/\\])\./.test(p)) return true
306
+ if (p.includes(`${path.sep}dist${path.sep}`) || p.endsWith(`${path.sep}dist`)) return true
307
+ return false
308
+ },
309
+ ignoreInitial: true,
310
+ persistent: true
311
+ })
312
+ const onFsEvent = () => {
313
+ if (isSuppressed()) return
314
+ sendLocalChanges()
315
+ }
316
+ watcher
317
+ .on('add', onFsEvent)
318
+ .on('change', onFsEvent)
319
+ .on('unlink', onFsEvent)
320
+
321
+ console.log(chalk.green('Watching local changes and syncing over socket...'))
322
+ console.log(chalk.gray('Press Ctrl+C to exit'))
323
+ }
324
+
325
+ program
326
+ .command('collab')
327
+ .description('Connect to realtime collaboration socket and live-sync changes')
328
+ .option('-b, --branch <branch>', 'Branch to collaborate on')
329
+ .option('-l, --live', 'Enable live collaboration mode', false)
330
+ .option('-d, --debounce-ms <ms>', 'Local changes debounce milliseconds', (v) => parseInt(v, 10), 200)
331
+ .option('-v, --verbose', 'Show verbose output', false)
332
+ .action(startCollab)
333
+
334
+
package/bin/fetch.js CHANGED
@@ -1,79 +1,101 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import fs from 'fs'
4
+ import path from 'path'
4
5
  import chalk from 'chalk'
5
- import { loadModule } from './require.js'
6
6
  import { program } from './program.js'
7
- import * as fetch from '@symbo.ls/fetch'
8
7
  import * as utils from '@domql/utils'
9
8
  import { convertFromCli } from './convert.js'
10
9
  import { createFs } from './fs.js'
11
- const { isObjectLike } = utils.default || utils
12
- const { fetchRemote } = fetch.default || fetch
10
+ import { CredentialManager } from '../helpers/credentialManager.js'
11
+ import { getCurrentProjectData } from '../helpers/apiUtils.js'
12
+ import { showAuthRequiredMessages } from '../helpers/buildMessages.js'
13
+ import { loadSymbolsConfig } from '../helpers/symbolsConfig.js'
14
+ import { loadCliConfig, readLock, writeLock, updateLegacySymbolsJson, getConfigPaths } from '../helpers/config.js'
15
+ const { isObjectLike } = (utils.default || utils)
13
16
 
14
17
  const RC_PATH = process.cwd() + '/symbols.json'
15
18
  const LOCAL_CONFIG_PATH =
16
19
  process.cwd() + '/node_modules/@symbo.ls/init/dynamic.json'
17
20
  const DEFAULT_REMOTE_REPOSITORY = 'https://github.com/symbo-ls/default-config/'
18
- const DEFAULT_REMOTE_CONFIG_PATH = 'https://api.symbols.app/' // eslint-disable-line
19
-
20
- const API_URL_LOCAL = 'http://localhost:8080/get'
21
- const API_URL = 'https://api.symbols.app/get'
22
-
23
- const rcFile = loadModule(RC_PATH) // eslint-disable-line
24
- const localConfig = loadModule(LOCAL_CONFIG_PATH) // eslint-disable-line
25
21
 
26
22
  const debugMsg = chalk.dim(
27
23
  'Use --verbose to debug the error or open the issue at https://github.com/symbo-ls/smbls'
28
24
  )
29
25
 
30
- let rc = {}
31
- try {
32
- rc = loadModule(RC_PATH) // eslint-disable-line
33
- } catch (e) {
34
- console.error('Please include symbols.json to your root of respository')
35
- }
36
-
37
26
  export const fetchFromCli = async (opts) => {
38
- const {
39
- dev,
40
- verbose,
41
- prettify,
42
- convert: convertOpt,
43
- metadata: metadataOpt,
44
- update,
45
- force
46
- } = opts
47
- await rc.then(async (data) => {
48
- const { key, framework, distDir, metadata } = data || {}
49
-
50
- const endpoint = dev || utils.isLocal() ? API_URL_LOCAL : API_URL
51
-
52
- console.log('\nFetching from:', chalk.bold(endpoint), '\n')
53
-
54
- const body = await fetchRemote(key, {
55
- endpoint,
56
- metadata: metadata || metadataOpt,
57
- onError: (e) => {
58
- console.log(chalk.red('Failed to fetch:'), key)
59
- if (verbose) console.error(e)
60
- else console.log(debugMsg)
27
+ const { dev, verbose, prettify, convert: convertOpt, metadata: metadataOpt, update, force } = opts
28
+
29
+ const credManager = new CredentialManager()
30
+ const authToken = credManager.ensureAuthToken()
31
+
32
+ if (!authToken) {
33
+ showAuthRequiredMessages()
34
+
35
+ process.exit(1)
36
+ }
37
+
38
+ const symbolsConfig = await loadSymbolsConfig()
39
+ const cliConfig = loadCliConfig()
40
+ const projectKey = cliConfig.projectKey || symbolsConfig.key
41
+ const branch = cliConfig.branch || symbolsConfig.branch || 'main'
42
+ const { framework, distDir, metadata } = symbolsConfig
43
+
44
+ console.log('\nFetching project data...\n')
45
+
46
+ let payload
47
+ try {
48
+ const lock = readLock()
49
+ const result = await getCurrentProjectData(
50
+ { projectKey, projectId: lock.projectId },
51
+ authToken,
52
+ { branch, includePending: true, etag: lock.etag }
53
+ )
54
+
55
+ if (result.notModified) {
56
+ console.log(chalk.bold.green('Already up to date (ETag matched)'))
57
+ return
61
58
  }
62
- })
63
59
 
64
- // console.log('ON FETCH:')
65
- // console.log(body.components.Configuration)
60
+ payload = result.data || {}
61
+ const etag = result.etag || null
66
62
 
67
- if (!body || body.error) return
63
+ // Update lock.json
64
+ writeLock({
65
+ etag,
66
+ version: payload.version,
67
+ branch,
68
+ projectId: payload?.projectInfo?.id || lock.projectId,
69
+ pulledAt: new Date().toISOString()
70
+ })
68
71
 
69
- const { version, ...config } = body
72
+ // Update legacy symbols.json with version and branch
73
+ updateLegacySymbolsJson({ ...(symbolsConfig || {}), version: payload.version, branch })
74
+
75
+ if (verbose) {
76
+ console.log(chalk.gray(`Version: ${chalk.cyan(payload.version)}`))
77
+ console.log(chalk.gray(`Branch: ${chalk.cyan(branch)}\n`))
78
+ }
79
+ } catch (e) {
80
+ console.log(chalk.red('Failed to fetch:'), projectKey)
81
+ if (verbose) console.error(e)
82
+ else console.log(debugMsg)
83
+ return
84
+ }
85
+
86
+ // Persist base snapshot for future rebases
87
+ try {
88
+ const { projectPath } = getConfigPaths()
89
+ await fs.promises.mkdir(path.dirname(projectPath), { recursive: true })
90
+ await fs.promises.writeFile(projectPath, JSON.stringify(payload, null, 2))
91
+ } catch (_) {}
70
92
 
71
93
  if (verbose) {
72
- if (key) {
94
+ if (projectKey) {
73
95
  console.log(
74
96
  chalk.bold('Symbols'),
75
97
  'data fetched for',
76
- chalk.green(body.name)
98
+ chalk.green(payload.name)
77
99
  )
78
100
  } else {
79
101
  console.log(
@@ -86,6 +108,8 @@ export const fetchFromCli = async (opts) => {
86
108
  console.log()
87
109
  }
88
110
 
111
+ const { version: fetchedVersion, ...config } = payload
112
+
89
113
  for (const t in config) {
90
114
  const type = config[t]
91
115
  const arr = []
@@ -101,7 +125,7 @@ export const fetchFromCli = async (opts) => {
101
125
  }
102
126
 
103
127
  if (!distDir) {
104
- const bodyString = JSON.stringify(body, null, prettify ?? 2)
128
+ const bodyString = JSON.stringify(payload, null, prettify ?? 2)
105
129
 
106
130
  try {
107
131
  await fs.writeFileSync(LOCAL_CONFIG_PATH, bodyString)
@@ -125,16 +149,15 @@ export const fetchFromCli = async (opts) => {
125
149
  return {}
126
150
  }
127
151
 
128
- if (body.components && convertOpt && framework) {
129
- convertFromCli(body.components, { ...opts, framework })
152
+ if (payload.components && convertOpt && framework) {
153
+ convertFromCli(payload.components, { ...opts, framework })
130
154
  }
131
155
 
132
156
  if (update || force) {
133
- createFs(body, distDir, { update: true, metadata })
157
+ createFs(payload, distDir, { update: true, metadata: false })
134
158
  } else {
135
- createFs(body, distDir, { metadata })
159
+ createFs(payload, distDir, { metadata: false })
136
160
  }
137
- })
138
161
  }
139
162
 
140
163
  program