@sublay/cli 7.0.1 → 7.1.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 (3) hide show
  1. package/README.md +22 -13
  2. package/dist/index.js +531 -108
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -51,18 +51,27 @@ Sublay ships these layers as **pre-modeled bundles** that attach to one shared e
51
51
 
52
52
  ## Bundles you can install
53
53
 
54
- - **Comments & Threads** — threaded discussions with mentions, replies, sorting
55
- - **Reactions & Votes** — upvotes, downvotes, multi-emoji reactions
56
- - **Spaces & Communities** — groups, sub-forums, membership
57
- - **Notifications** — in-app notification fan-out with optional webhook delivery
58
- - **Feeds & Discovery** — hot / top / new / controversial, filtered by tag, geography, timeframe, or follow graph
59
- - **Real-Time Chat** — direct messages and group threads
60
- - **Follows & Connections** — one-way follows, mutual connections
61
- - **Files & Storage** — file blob storage with managed upload URLs
62
- - **AI-Powered Search** — semantic search across your entity tree
63
- - **Moderation** — report queues and content-removal workflows
64
-
65
- Every bundle attaches to the same model. No mismatches, no extra databases. One schema for everything.
54
+ Every project always includes the **`core`** bundle — users, authentication, and OAuth identity — installed automatically and it can't be removed. On top of that, install only what your product needs:
55
+
56
+ - **Entities** (`entities`) — the base content unit for anything your users create (posts, articles, listings), with views, drafts, and publishing
57
+ - **Comments** (`comments`) — threaded comments on entities · requires `entities`
58
+ - **Reactions** (`reactions`) — emoji reactions on entities and comments
59
+ - **Storage** (`files-images`) — file and image uploads, including user avatars and banners
60
+ - **Follows** (`follows`) — one-way follow relationships
61
+ - **Connections** (`connections`) — bidirectional, friend-style connection requests
62
+ - **Spaces** (`spaces`) — hierarchical community spaces with membership, roles, and rules
63
+ - **Workspaces** (`workspaces`) — self-nesting SaaS/team workspaces with invitations and per-member authority
64
+ - **Chat** (`chat`) — real-time 1:1 and group conversations with message reactions
65
+ - **Collections** (`collections`) — user-owned bookmarking and folders for saving entities · requires `entities`
66
+ - **Events** (`events`) — online, physical, or hybrid events with RSVP tracking, capacity limits, hosts, and invites
67
+ - **Moderation** (`moderation`) — reports, report resolution, user blocking, and account suspensions
68
+ - **App Notifications** (`notifications`) — in-app notification fan-out
69
+ - **Push Notifications** (`push`) — native push delivery to iOS, Android, and Web
70
+ - **Reputation** (`reputation`) — per-space reputation buckets, a maintained overall total per user, and reputation grants
71
+ - **Semantic Search & AI** (`ai-search`) — content embeddings, semantic search, and an AI-answer endpoint
72
+ - **Interest Matching** (`interest-matching`) — activity-derived interest facets for matching users by what they engage with · requires `ai-search`
73
+
74
+ Every bundle attaches to the same project schema — no mismatches, no extra databases. Install, remove, or add bundles at any time from the dashboard's Database page; removing a bundle drops its tables (and clears references to it from other bundles) for good.
66
75
 
67
76
  ## How it works
68
77
 
@@ -84,7 +93,7 @@ The dashboard at [dash.sublay.io](https://dash.sublay.io) is a database/backend
84
93
 
85
94
  ## The Sublay SDK family
86
95
 
87
- - [`@sublay/core`](https://www.npmjs.com/package/@sublay/core) — platform-agnostic hooks and utilities
96
+ - [`@sublay/core`](https://www.npmjs.com/package/@sublay/core) — hooks and utilities shared across the React, React Native, and Expo SDKs
88
97
  - [`@sublay/react-js`](https://www.npmjs.com/package/@sublay/react-js) — React (web)
89
98
  - [`@sublay/react-native`](https://www.npmjs.com/package/@sublay/react-native) — React Native
90
99
  - [`@sublay/expo`](https://www.npmjs.com/package/@sublay/expo) — Expo with SecureStore token storage
package/dist/index.js CHANGED
@@ -151,79 +151,283 @@ var __filename = fileURLToPath(import.meta.url);
151
151
  var __dirname = path3.dirname(__filename);
152
152
  var LOCAL_REGISTRY_PATH = path3.resolve(__dirname, "..", "..", "..", "registry");
153
153
  var REMOTE_REGISTRY_BASE = "https://raw.githubusercontent.com/sublay-io/monorepo/main/registry";
154
- async function fetchRegistry(componentName, config) {
154
+ var DEFAULT_REGISTRY_REF = "main";
155
+ var LOCAL_PATH_ENV = "SUBLAY_REGISTRY_LOCAL_PATH";
156
+ var REMOTE_BASE_ENV = "SUBLAY_REGISTRY_BASE_URL";
157
+ var RegistryError = class extends Error {
158
+ constructor(message) {
159
+ super(message);
160
+ this.name = "RegistryError";
161
+ }
162
+ };
163
+ function validateRef(ref) {
164
+ if (typeof ref !== "string" || ref.length === 0) {
165
+ return "A ref cannot be empty.";
166
+ }
167
+ if (!/^[A-Za-z0-9._\-/]+$/.test(ref)) {
168
+ return `Invalid ref "${ref}": a ref may only contain letters, digits, ".", "_", "-" and "/".`;
169
+ }
170
+ const segments = ref.split("/");
171
+ if (segments.some((segment) => segment === "..")) {
172
+ return `Invalid ref "${ref}": a ref cannot contain a ".." segment.`;
173
+ }
174
+ if (segments.some((segment) => segment.length === 0)) {
175
+ return `Invalid ref "${ref}": a ref cannot start or end with "/" or contain "//".`;
176
+ }
177
+ return null;
178
+ }
179
+ function validateComponentName(name) {
180
+ if (typeof name !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
181
+ return `Invalid component name "${name}": a component name may only contain lowercase letters, digits and hyphens (for example "comments-threaded").`;
182
+ }
183
+ return null;
184
+ }
185
+ function getRemoteRegistryBase(ref = DEFAULT_REGISTRY_REF, env = process.env) {
186
+ const base = (env[REMOTE_BASE_ENV] || REMOTE_REGISTRY_BASE).replace(/\/+$/, "");
187
+ const suffix = `/${DEFAULT_REGISTRY_REF}/registry`;
188
+ if (!base.endsWith(suffix)) {
189
+ throw new RegistryError(
190
+ `Registry base URL "${base}" must end with "${suffix}".`
191
+ );
192
+ }
193
+ if (ref === DEFAULT_REGISTRY_REF) {
194
+ return base;
195
+ }
196
+ return `${base.slice(0, -suffix.length)}/${ref}/registry`;
197
+ }
198
+ function resolveRegistrySource(ref, env = process.env) {
199
+ if (ref === void 0) {
200
+ return {
201
+ ref: DEFAULT_REGISTRY_REF,
202
+ explicitRef: false,
203
+ localRoot: env[LOCAL_PATH_ENV] || LOCAL_REGISTRY_PATH,
204
+ remoteBase: getRemoteRegistryBase(DEFAULT_REGISTRY_REF, env)
205
+ };
206
+ }
207
+ const problem = validateRef(ref);
208
+ if (problem) {
209
+ throw new RegistryError(problem);
210
+ }
211
+ return {
212
+ ref,
213
+ explicitRef: true,
214
+ localRoot: null,
215
+ remoteBase: getRemoteRegistryBase(ref, env)
216
+ };
217
+ }
218
+ function getRegistryPlatform(config) {
219
+ return config.platform === "expo" ? "react-native" : config.platform;
220
+ }
221
+ function getComponentLocation(componentName, config) {
222
+ const problem = validateComponentName(componentName);
223
+ if (problem) {
224
+ throw new RegistryError(problem);
225
+ }
226
+ return `${getRegistryPlatform(config)}/${componentName}/${config.style}`;
227
+ }
228
+ function getRemoteUrl(source, registryPath) {
229
+ return `${source.remoteBase}/${registryPath}`;
230
+ }
231
+ async function readRegistryPath(source, registryPath) {
232
+ if (source.localRoot) {
233
+ const localPath = path3.join(source.localRoot, ...registryPath.split("/"));
234
+ if (await fs3.pathExists(localPath)) {
235
+ return { ok: true, content: await fs3.readFile(localPath, "utf-8") };
236
+ }
237
+ if (source.localOnly) {
238
+ return {
239
+ ok: false,
240
+ url: localPath,
241
+ status: 404,
242
+ reason: "not found in the local registry"
243
+ };
244
+ }
245
+ }
246
+ const url = getRemoteUrl(source, registryPath);
155
247
  try {
156
- const registryPlatform = config.platform === "expo" ? "react-native" : config.platform;
157
- const registryPath = path3.join(
158
- LOCAL_REGISTRY_PATH,
159
- registryPlatform,
160
- componentName,
161
- config.style,
162
- "registry.json"
163
- );
164
- const localExists = await fs3.pathExists(registryPath);
165
- if (localExists) {
166
- return await fs3.readJson(registryPath);
167
- }
168
- const url = `${REMOTE_REGISTRY_BASE}/${registryPlatform}/${componentName}/${config.style}/registry.json`;
169
- try {
170
- const response = await fetch(url);
171
- if (!response.ok) {
172
- if (response.status === 404) {
173
- console.error(`Component "${componentName}" with style "${config.style}" not found in registry.`);
174
- } else {
175
- console.error(`Failed to fetch registry from ${url}: ${response.status} ${response.statusText}`);
176
- }
177
- return null;
178
- }
179
- return await response.json();
180
- } catch (fetchError) {
181
- console.error(`Error fetching from GitHub (${url}):`, fetchError);
182
- return null;
248
+ const response = await fetch(url);
249
+ if (!response.ok) {
250
+ return {
251
+ ok: false,
252
+ url,
253
+ status: response.status,
254
+ reason: `${response.status} ${response.statusText}`.trim()
255
+ };
183
256
  }
257
+ return { ok: true, content: await response.text() };
184
258
  } catch (error) {
185
- console.error("Error fetching registry:", error);
186
- return null;
259
+ return {
260
+ ok: false,
261
+ url,
262
+ reason: error instanceof Error ? error.message : String(error)
263
+ };
187
264
  }
188
265
  }
189
- async function fetchFile(registryUrl, filePath) {
190
- try {
191
- const urlParts = registryUrl.split("/");
192
- const style = urlParts[urlParts.length - 1];
193
- const componentName = urlParts[urlParts.length - 2];
194
- const platform = urlParts[urlParts.length - 3];
195
- const localPath = path3.join(
196
- LOCAL_REGISTRY_PATH,
197
- platform,
198
- componentName,
199
- style,
200
- filePath
201
- );
202
- const localExists = await fs3.pathExists(localPath);
203
- if (localExists) {
204
- return await fs3.readFile(localPath, "utf-8");
205
- }
206
- const fileUrl = `${registryUrl}/${filePath}`;
266
+ async function pinRegistrySource(source, componentName, config) {
267
+ if (!source.localRoot) {
268
+ return source;
269
+ }
270
+ const location = getComponentLocation(componentName, config);
271
+ const manifestPath = path3.join(
272
+ source.localRoot,
273
+ ...location.split("/"),
274
+ "registry.json"
275
+ );
276
+ return await fs3.pathExists(manifestPath) ? { ...source, localOnly: true } : { ...source, localRoot: null };
277
+ }
278
+ async function fetchRegistry(componentName, config, source) {
279
+ const location = getComponentLocation(componentName, config);
280
+ const result = await readRegistryPath(source, `${location}/registry.json`);
281
+ if (result.ok) {
207
282
  try {
208
- const response = await fetch(fileUrl);
209
- if (!response.ok) {
210
- console.error(`Failed to fetch file from ${fileUrl}: ${response.status} ${response.statusText}`);
211
- return null;
212
- }
213
- return await response.text();
214
- } catch (fetchError) {
215
- console.error(`Error fetching file from GitHub (${fileUrl}):`, fetchError);
216
- return null;
283
+ return JSON.parse(result.content);
284
+ } catch {
285
+ throw new RegistryError(
286
+ `The registry manifest for "${componentName}" (${location}/registry.json) is not valid JSON.`
287
+ );
217
288
  }
218
- } catch (error) {
219
- console.error(`Error fetching file ${filePath}:`, error);
220
- return null;
221
289
  }
290
+ if (result.status !== 404) {
291
+ throw new RegistryError(
292
+ `Failed to fetch registry from ${result.url}: ${result.reason}`
293
+ );
294
+ }
295
+ if (!source.explicitRef) {
296
+ throw new RegistryError(
297
+ `Component "${componentName}" with style "${config.style}" not found in registry.`
298
+ );
299
+ }
300
+ const probe = await readRegistryPath(source, "package.json");
301
+ if (probe.ok) {
302
+ throw new RegistryError(
303
+ `Component "${componentName}" with style "${config.style}" not found on branch "${source.ref}".`
304
+ );
305
+ }
306
+ if (probe.status === 404) {
307
+ throw new RegistryError(
308
+ `Branch "${source.ref}" not found: no registry is served from that branch.`
309
+ );
310
+ }
311
+ throw new RegistryError(
312
+ `Failed to fetch registry from ${probe.url}: ${probe.reason}`
313
+ );
314
+ }
315
+ async function fetchFile(source, location, filePath) {
316
+ const result = await readRegistryPath(source, `${location}/${filePath}`);
317
+ if (!result.ok) {
318
+ throw new RegistryError(
319
+ `Could not fetch ${filePath} from ${result.url}: ${result.reason}`
320
+ );
321
+ }
322
+ return result.content;
323
+ }
324
+ function getSharedManifestPath(platform) {
325
+ return `shared/${platform}/shared.json`;
326
+ }
327
+ function isStringArray(value) {
328
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
329
+ }
330
+ function parseSharedManifest(raw, label) {
331
+ const invalid = (detail) => new RegistryError(`The shared manifest ${label} is malformed: ${detail}.`);
332
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
333
+ throw invalid("it must be an object");
334
+ }
335
+ const { platform, pieces } = raw;
336
+ if (typeof pieces !== "object" || pieces === null || Array.isArray(pieces)) {
337
+ throw invalid('"pieces" must be an object keyed by piece name');
338
+ }
339
+ const parsed = {};
340
+ for (const [name, value] of Object.entries(pieces)) {
341
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
342
+ throw invalid(`piece "${name}" must be an object`);
343
+ }
344
+ const { files, requires = [], dependencies = [] } = value;
345
+ if (!isStringArray(files) || files.length === 0) {
346
+ throw invalid(`piece "${name}" must list at least one file`);
347
+ }
348
+ const outside = files.find(
349
+ (file) => !/^[A-Za-z0-9_][A-Za-z0-9._/-]*$/.test(file) || file.split("/").includes("..")
350
+ );
351
+ if (outside !== void 0) {
352
+ throw invalid(
353
+ `piece "${name}" lists "${outside}", which is not a path inside the shared folder`
354
+ );
355
+ }
356
+ if (!isStringArray(requires)) {
357
+ throw invalid(`piece "${name}" has a "requires" that is not a list of piece names`);
358
+ }
359
+ if (!isStringArray(dependencies)) {
360
+ throw invalid(`piece "${name}" has a "dependencies" that is not a list of packages`);
361
+ }
362
+ parsed[name] = { files, requires, dependencies };
363
+ }
364
+ return {
365
+ platform: typeof platform === "string" ? platform : "",
366
+ pieces: parsed
367
+ };
368
+ }
369
+ async function fetchSharedManifest(platform, source) {
370
+ const manifestPath = getSharedManifestPath(platform);
371
+ const result = await readRegistryPath(source, manifestPath);
372
+ if (!result.ok) {
373
+ throw new RegistryError(
374
+ `Could not fetch the shared manifest ${manifestPath} from ${result.url}: ${result.reason}`
375
+ );
376
+ }
377
+ let raw;
378
+ try {
379
+ raw = JSON.parse(result.content);
380
+ } catch {
381
+ throw new RegistryError(`The shared manifest ${manifestPath} is not valid JSON.`);
382
+ }
383
+ return parseSharedManifest(raw, manifestPath);
384
+ }
385
+ function resolveSharedPieces(names, manifest, labels) {
386
+ const resolved = [];
387
+ const visited = /* @__PURE__ */ new Set();
388
+ const visit = (name, requiredBy) => {
389
+ if (visited.has(name)) {
390
+ return;
391
+ }
392
+ if (!Object.prototype.hasOwnProperty.call(manifest.pieces, name)) {
393
+ throw new RegistryError(
394
+ requiredBy === null ? `Shared piece "${name}", listed in ${labels.componentManifest}, is not defined in ${labels.sharedManifest}.` : `Shared piece "${requiredBy}" requires "${name}", which is not defined in ${labels.sharedManifest}.`
395
+ );
396
+ }
397
+ visited.add(name);
398
+ const piece = manifest.pieces[name];
399
+ for (const requirement of piece.requires) {
400
+ visit(requirement, name);
401
+ }
402
+ resolved.push({ name, ...piece });
403
+ };
404
+ for (const name of names) {
405
+ visit(name, null);
406
+ }
407
+ return resolved;
408
+ }
409
+ async function fetchSharedFile(source, platform, filePath) {
410
+ return fetchFile(source, `shared/${platform}`, filePath);
222
411
  }
223
412
 
224
413
  // src/utils/transform.ts
225
- function transformImports(content, config) {
414
+ var SHARED_ALIAS_IMPORT = /\b((?:from|import|require)\s*\(?\s*)(['"])~shared\/([^'"/]+)\/([^'"]+)\2/g;
415
+ function findSharedAliasImports(content) {
416
+ return Array.from(content.matchAll(SHARED_ALIAS_IMPORT), (match) => ({
417
+ platform: match[3],
418
+ piece: match[4]
419
+ }));
420
+ }
421
+ function transformImports(content, config, options = {}) {
226
422
  let transformed = content;
423
+ const { installedPath } = options;
424
+ if (installedPath !== void 0) {
425
+ const fromDir = posixDirname(installedPath);
426
+ transformed = transformed.replace(
427
+ SHARED_ALIAS_IMPORT,
428
+ (_match, lead, quote, _platform, piece) => `${lead}${quote}${relativeImportPath(fromDir, `shared/${piece}`)}${quote}`
429
+ );
430
+ }
227
431
  transformed = transformed.replace(/from(\s+)(['"])\.\.\/files\//g, "from$1$2../components/");
228
432
  transformed = transformed.replace(/import(\s+)(['"])\.\.\/files\//g, "import$1$2../components/");
229
433
  transformed = transformed.replace(/import\((['"])\.\.\/files\//g, "import($1../components/");
@@ -232,11 +436,30 @@ function transformImports(content, config) {
232
436
  /from\s+["']@sublay\/react-native["']/g,
233
437
  'from "@sublay/expo"'
234
438
  );
439
+ transformed = transformed.replace(
440
+ /^(\s*\*\s+-\s+)@sublay\/react-native(?=\s|$)/gm,
441
+ "$1@sublay/expo"
442
+ );
235
443
  }
236
444
  if (config.aliases["@/components"]) {
237
445
  }
238
446
  return transformed;
239
447
  }
448
+ function posixDirname(filePath) {
449
+ const index = filePath.lastIndexOf("/");
450
+ return index === -1 ? "" : filePath.slice(0, index);
451
+ }
452
+ function relativeImportPath(fromDir, target) {
453
+ const from = fromDir.split("/").filter(Boolean);
454
+ const to = target.split("/").filter(Boolean);
455
+ let common = 0;
456
+ while (common < from.length && common < to.length - 1 && from[common] === to[common]) {
457
+ common++;
458
+ }
459
+ const up = from.length - common;
460
+ const down = to.slice(common).join("/");
461
+ return up === 0 ? `./${down}` : `${"../".repeat(up)}${down}`;
462
+ }
240
463
 
241
464
  // src/utils/dependencies.ts
242
465
  import fs4 from "fs-extra";
@@ -244,6 +467,7 @@ import path4 from "path";
244
467
  import chalk2 from "chalk";
245
468
  import prompts2 from "prompts";
246
469
  import { execa } from "execa";
470
+ import semver from "semver";
247
471
  async function installDependencies(deps) {
248
472
  try {
249
473
  console.log(chalk2.blue(`
@@ -280,6 +504,55 @@ function parseDependencyName(dep) {
280
504
  }
281
505
  return parts[0];
282
506
  }
507
+ function parseDependencySpec(dep) {
508
+ const name = parseDependencyName(dep);
509
+ const rest = dep.slice(name.length);
510
+ return { name, range: rest.startsWith("@") ? rest.slice(1) : "" };
511
+ }
512
+ function mergeDependencies(componentDependencies, sharedDependencies) {
513
+ const merged = [];
514
+ const seen = /* @__PURE__ */ new Set();
515
+ for (const dep of [...componentDependencies, ...sharedDependencies]) {
516
+ const name = parseDependencyName(dep);
517
+ if (seen.has(name)) {
518
+ continue;
519
+ }
520
+ seen.add(name);
521
+ merged.push(dep);
522
+ }
523
+ return merged;
524
+ }
525
+ function isDeclaredRangeBelow(declared, required) {
526
+ const options = { includePrerelease: true };
527
+ if (typeof declared !== "string" || !required || !semver.validRange(declared, options) || !semver.validRange(required, options)) {
528
+ return false;
529
+ }
530
+ if (semver.intersects(declared, required, options)) {
531
+ return false;
532
+ }
533
+ const declaredMin = semver.minVersion(declared);
534
+ const requiredMin = semver.minVersion(required);
535
+ if (!declaredMin || !requiredMin) {
536
+ return false;
537
+ }
538
+ return semver.lt(declaredMin, requiredMin);
539
+ }
540
+ function findDependencyStatus(dependencies, declaredDependencies) {
541
+ const missing = [];
542
+ const upgrades = [];
543
+ for (const spec of dependencies) {
544
+ const { name, range } = parseDependencySpec(spec);
545
+ const declared = declaredDependencies[name];
546
+ if (!declared) {
547
+ missing.push(spec);
548
+ continue;
549
+ }
550
+ if (name.startsWith("@sublay/") && typeof declared === "string" && isDeclaredRangeBelow(declared, range)) {
551
+ upgrades.push({ spec, declared });
552
+ }
553
+ }
554
+ return { missing, upgrades };
555
+ }
283
556
  async function checkComponentDependencies(dependencies) {
284
557
  try {
285
558
  const packageJsonPath = path4.join(process.cwd(), "package.json");
@@ -292,17 +565,22 @@ async function checkComponentDependencies(dependencies) {
292
565
  ...packageJson2.dependencies,
293
566
  ...packageJson2.devDependencies
294
567
  };
295
- const requiredDeps = dependencies.map(parseDependencyName);
296
- const missingDeps = requiredDeps.filter((dep) => !allDeps[dep]);
297
- if (missingDeps.length === 0) {
568
+ const { missing, upgrades } = findDependencyStatus(dependencies, allDeps);
569
+ if (missing.length === 0 && upgrades.length === 0) {
298
570
  console.log(chalk2.green("\n\u2705 All required dependencies are installed"));
299
571
  return;
300
572
  }
301
- console.log(chalk2.yellow("\n\u26A0\uFE0F Missing required dependencies:"));
302
- const missingDepsWithVersions = dependencies.filter(
303
- (dep) => missingDeps.includes(parseDependencyName(dep))
304
- );
305
- missingDepsWithVersions.forEach((dep) => console.log(chalk2.dim(` - ${dep}`)));
573
+ if (missing.length > 0) {
574
+ console.log(chalk2.yellow("\n\u26A0\uFE0F Missing required dependencies:"));
575
+ missing.forEach((dep) => console.log(chalk2.dim(` - ${dep}`)));
576
+ }
577
+ if (upgrades.length > 0) {
578
+ console.log(chalk2.yellow("\n\u26A0\uFE0F Dependencies that need an upgrade:"));
579
+ upgrades.forEach(
580
+ ({ spec, declared }) => console.log(chalk2.dim(` - ${spec} (your project has ${declared})`))
581
+ );
582
+ }
583
+ const toInstall = [...missing, ...upgrades.map((upgrade) => upgrade.spec)];
306
584
  let install = false;
307
585
  if (process.stdin.isTTY) {
308
586
  const answer = await prompts2({
@@ -314,12 +592,12 @@ async function checkComponentDependencies(dependencies) {
314
592
  install = Boolean(answer.install);
315
593
  }
316
594
  if (install) {
317
- await installDependencies(missingDepsWithVersions);
595
+ await installDependencies(toInstall);
318
596
  } else {
319
597
  console.log(chalk2.dim("\nYou can install them later with:"));
320
598
  const packageManager = await detectPackageManager();
321
599
  const installCmd = packageManager === "npm" ? "npm install" : packageManager === "yarn" ? "yarn add" : "pnpm add";
322
- console.log(chalk2.dim(` ${installCmd} ${missingDepsWithVersions.join(" ")}
600
+ console.log(chalk2.dim(` ${installCmd} ${toInstall.join(" ")}
323
601
  `));
324
602
  }
325
603
  } catch (error) {
@@ -356,7 +634,7 @@ async function stripTypes(code, filePath) {
356
634
  retainLines: true
357
635
  // Try to keep line numbers similar
358
636
  });
359
- if (!result || !result.code) {
637
+ if (!result || typeof result.code !== "string") {
360
638
  throw new Error("Babel transformation returned no code");
361
639
  }
362
640
  return result.code;
@@ -373,9 +651,26 @@ function convertFileExtension(filePath) {
373
651
  }
374
652
 
375
653
  // src/commands/add.ts
376
- async function add(componentName) {
654
+ var STRINGS_FILE = "utils/strings.ts";
655
+ var DEFAULT_LANGUAGE = "en";
656
+ var FETCH_CONCURRENCY = 8;
657
+ async function add(componentName, options = {}) {
377
658
  const spinner = ora("Initializing...").start();
378
659
  try {
660
+ let source;
661
+ try {
662
+ const nameProblem = validateComponentName(componentName);
663
+ if (nameProblem) {
664
+ throw new RegistryError(nameProblem);
665
+ }
666
+ source = resolveRegistrySource(options.ref);
667
+ } catch (error) {
668
+ if (error instanceof RegistryError) {
669
+ spinner.fail(error.message);
670
+ process.exit(1);
671
+ }
672
+ throw error;
673
+ }
379
674
  const configPath = path5.join(process.cwd(), "sublay.json");
380
675
  if (!await fs5.pathExists(configPath)) {
381
676
  spinner.fail("No sublay.json found");
@@ -411,38 +706,69 @@ async function add(componentName) {
411
706
  spinner.start(`Fetching ${componentName}...`);
412
707
  }
413
708
  spinner.text = `Fetching ${componentName}...`;
414
- const registry = await fetchRegistry(componentName, config);
415
- if (!registry) {
416
- spinner.fail(`Component "${componentName}" not found`);
417
- process.exit(1);
418
- }
419
- if (config.platform === "expo") {
420
- registry.dependencies = registry.dependencies.map(
421
- (dep) => dep.startsWith("@sublay/react-native") ? dep.replace("@sublay/react-native", "@sublay/expo") : dep
422
- );
709
+ source = await pinRegistrySource(source, componentName, config);
710
+ const registry = await fetchRegistry(componentName, config, source);
711
+ registry.dependencies = registry.dependencies.map(
712
+ (dep) => renameForPlatform(dep, config)
713
+ );
714
+ const languageFile = resolveLanguageFile(registry, options.lang, componentName);
715
+ const platform = getRegistryPlatform(config);
716
+ const location = getComponentLocation(componentName, config);
717
+ const componentManifestLabel = `${location}/registry.json`;
718
+ const sharedManifestLabel = getSharedManifestPath(platform);
719
+ const sharedNames = readSharedNames(registry, componentManifestLabel);
720
+ let sharedPieces = [];
721
+ if (sharedNames.length > 0) {
722
+ const sharedManifest = await fetchSharedManifest(platform, source);
723
+ sharedPieces = resolveSharedPieces(sharedNames, sharedManifest, {
724
+ componentManifest: componentManifestLabel,
725
+ sharedManifest: sharedManifestLabel
726
+ });
423
727
  }
424
- spinner.text = "Downloading files...";
425
- let filesInstalled = 0;
426
- for (const file of registry.files) {
427
- if (shouldExcludeFile(file.path)) {
428
- continue;
429
- }
430
- const fileContent = await fetchFile(registry.registryUrl, file.path);
431
- if (!fileContent) {
432
- console.log(chalk3.yellow(`
433
- \u26A0\uFE0F Could not fetch ${file.path}`));
434
- continue;
435
- }
436
- let transformed = transformImports(fileContent, config);
437
- let finalFilePath = file.path;
438
- if (!config.typescript && (file.path.endsWith(".ts") || file.path.endsWith(".tsx"))) {
439
- transformed = await stripTypes(transformed, file.path);
440
- finalFilePath = convertFileExtension(file.path);
728
+ const dependencies = mergeDependencies(
729
+ registry.dependencies,
730
+ sharedPieces.flatMap((piece) => piece.dependencies).map((dep) => renameForPlatform(dep, config))
731
+ );
732
+ spinner.text = sharedPieces.length > 0 ? `Downloading files and ${sharedPieces.length} shared piece(s)...` : "Downloading files...";
733
+ const installedPieces = new Set(sharedPieces.map((piece) => piece.name));
734
+ const downloads = [
735
+ ...registry.files.map((file) => ({ registryPath: file.path, shared: false })),
736
+ ...sharedPieces.flatMap(
737
+ (piece) => piece.files.map((file) => ({ registryPath: file, shared: true }))
738
+ )
739
+ ].filter(
740
+ // Skip development files
741
+ (download) => !shouldExcludeFile(download.registryPath)
742
+ );
743
+ const pendingFiles = await mapWithConcurrency(
744
+ downloads,
745
+ FETCH_CONCURRENCY,
746
+ async ({ registryPath, shared }) => {
747
+ const fileContent = shared ? await fetchSharedFile(source, platform, registryPath) : await fetchFile(
748
+ source,
749
+ location,
750
+ languageFile && registryPath === STRINGS_FILE ? languageFile : registryPath
751
+ );
752
+ for (const used of findSharedAliasImports(fileContent)) {
753
+ if (used.platform !== platform || !installedPieces.has(used.piece)) {
754
+ throw new RegistryError(
755
+ `${registryPath} imports the shared piece "${used.platform}/${used.piece}", which ${componentManifestLabel} does not list under "shared" for ${sharedManifestLabel}.`
756
+ );
757
+ }
758
+ }
759
+ return prepareFile(
760
+ shared ? `shared/${registryPath}` : registryPath,
761
+ fileContent,
762
+ config,
763
+ componentName
764
+ );
441
765
  }
442
- const targetPath = getTargetPath(finalFilePath, config, componentName);
443
- const fullPath = path5.join(process.cwd(), targetPath);
766
+ );
767
+ let filesInstalled = 0;
768
+ for (const file of pendingFiles) {
769
+ const fullPath = path5.join(process.cwd(), file.targetPath);
444
770
  await fs5.ensureDir(path5.dirname(fullPath));
445
- await fs5.writeFile(fullPath, transformed, "utf-8");
771
+ await fs5.writeFile(fullPath, file.content, "utf-8");
446
772
  filesInstalled++;
447
773
  }
448
774
  spinner.text = "Creating index file...";
@@ -462,34 +788,120 @@ async function add(componentName) {
462
788
  )}`
463
789
  )
464
790
  );
465
- await checkComponentDependencies(registry.dependencies);
791
+ if (sharedPieces.length > 0) {
792
+ console.log(
793
+ chalk3.dim(
794
+ `\u{1F9E9} Shared pieces in ${path5.join(
795
+ config.paths.components,
796
+ componentName,
797
+ "shared"
798
+ )}: ${sharedPieces.map((piece) => piece.name).join(", ")}`
799
+ )
800
+ );
801
+ }
802
+ await checkComponentDependencies(dependencies);
466
803
  const componentInfo = getComponentInfo(registry);
467
- const relativeImportPath = path5.relative(
804
+ const relativeImportPath2 = path5.relative(
468
805
  process.cwd(),
469
806
  path5.join(config.paths.components, componentName)
470
807
  );
471
808
  console.log(chalk3.bold("\n\u{1F4D6} Usage:"));
472
809
  console.log(
473
810
  chalk3.dim(
474
- ` import { ${componentInfo.mainComponent} } from './${relativeImportPath}';`
811
+ ` import { ${componentInfo.mainComponent} } from './${relativeImportPath2}';`
475
812
  )
476
813
  );
477
814
  if (componentInfo.typeExport) {
478
815
  console.log(chalk3.dim(` // With types:`));
479
816
  console.log(
480
817
  chalk3.dim(
481
- ` import { ${componentInfo.mainComponent}, type ${componentInfo.typeExport} } from './${relativeImportPath}';`
818
+ ` import { ${componentInfo.mainComponent}, type ${componentInfo.typeExport} } from './${relativeImportPath2}';`
482
819
  )
483
820
  );
484
821
  }
485
- printAppSetupNotes(registry.dependencies);
822
+ printAppSetupNotes(dependencies);
486
823
  console.log();
487
824
  } catch (error) {
825
+ if (error instanceof RegistryError) {
826
+ spinner.fail(error.message);
827
+ console.error(chalk3.dim("\nNothing was written to your project."));
828
+ process.exit(1);
829
+ }
488
830
  spinner.fail("Failed to add component");
489
831
  console.error(chalk3.red("\n\u274C Error:"), error);
490
832
  process.exit(1);
491
833
  }
492
834
  }
835
+ async function prepareFile(filePath, fileContent, config, componentName) {
836
+ const componentDir = path5.join(config.paths.components, componentName);
837
+ const needsTypeStrip = !config.typescript && (filePath.endsWith(".ts") || filePath.endsWith(".tsx"));
838
+ const finalFilePath = needsTypeStrip ? convertFileExtension(filePath) : filePath;
839
+ const targetPath = getTargetPath(finalFilePath, config, componentName);
840
+ const installedPath = path5.relative(componentDir, targetPath).split(path5.sep).join("/");
841
+ if (installedPath === ".." || installedPath.startsWith("../") || path5.isAbsolute(installedPath)) {
842
+ throw new RegistryError(
843
+ `${filePath} would be installed outside ${componentDir}; refusing to write it.`
844
+ );
845
+ }
846
+ let transformed = transformImports(fileContent, config, { installedPath });
847
+ if (needsTypeStrip) {
848
+ transformed = await stripTypes(transformed, filePath);
849
+ }
850
+ return { targetPath, content: transformed };
851
+ }
852
+ function renameForPlatform(dep, config) {
853
+ return config.platform === "expo" && dep.startsWith("@sublay/react-native") ? dep.replace("@sublay/react-native", "@sublay/expo") : dep;
854
+ }
855
+ function resolveLanguageFile(registry, lang, componentName) {
856
+ if (lang === void 0 || lang === DEFAULT_LANGUAGE) {
857
+ return null;
858
+ }
859
+ const languages = registry.languages && typeof registry.languages === "object" ? registry.languages : {};
860
+ const available = [DEFAULT_LANGUAGE, ...Object.keys(languages)].join(", ");
861
+ const languageFile = Object.prototype.hasOwnProperty.call(languages, lang) ? languages[lang] : void 0;
862
+ if (languageFile === void 0) {
863
+ throw new RegistryError(
864
+ `${componentName} is not available in "${lang}". Available languages: ${available}.`
865
+ );
866
+ }
867
+ if (typeof languageFile !== "string" || !/^[A-Za-z0-9_][A-Za-z0-9._/-]*$/.test(languageFile) || languageFile.split("/").includes("..")) {
868
+ throw new RegistryError(
869
+ `The "${lang}" entry under "languages" for ${componentName} is not a usable file path.`
870
+ );
871
+ }
872
+ if (!registry.files.some((file) => file.path === STRINGS_FILE)) {
873
+ throw new RegistryError(
874
+ `${componentName} lists languages but has no ${STRINGS_FILE} to put them in.`
875
+ );
876
+ }
877
+ return languageFile;
878
+ }
879
+ function readSharedNames(registry, manifestLabel) {
880
+ const { shared } = registry;
881
+ if (shared === void 0 || shared === null) {
882
+ return [];
883
+ }
884
+ if (!Array.isArray(shared) || shared.some((name) => typeof name !== "string")) {
885
+ throw new RegistryError(
886
+ `"shared" in ${manifestLabel} must be a list of shared piece names.`
887
+ );
888
+ }
889
+ return shared;
890
+ }
891
+ async function mapWithConcurrency(items, limit, task) {
892
+ const results = new Array(items.length);
893
+ let next = 0;
894
+ const worker = async () => {
895
+ while (next < items.length) {
896
+ const index = next++;
897
+ results[index] = await task(items[index]);
898
+ }
899
+ };
900
+ await Promise.all(
901
+ Array.from({ length: Math.min(limit, items.length) }, worker)
902
+ );
903
+ return results;
904
+ }
493
905
  function isValidConfig(value) {
494
906
  if (typeof value !== "object" || value === null) {
495
907
  return false;
@@ -565,6 +977,9 @@ function getTargetPath(filePath, config, componentName) {
565
977
  if (filePath.startsWith("context/")) {
566
978
  return path5.join(componentDir, "context", path5.basename(filePath));
567
979
  }
980
+ if (filePath.startsWith("shared/")) {
981
+ return path5.join(componentDir, "shared", filePath.substring(7));
982
+ }
568
983
  if (filePath.startsWith("files/")) {
569
984
  const normalizedPath = filePath.substring(6);
570
985
  return path5.join(componentDir, "components", normalizedPath);
@@ -622,5 +1037,13 @@ var packageJson = require2("../package.json");
622
1037
  var program = new Command();
623
1038
  program.name("sublay").description("CLI for installing Sublay UI components").version(packageJson.version);
624
1039
  program.command("init").description("Initialize Sublay configuration in your project").action(init);
625
- program.command("add <component>").description("Add a component to your project").action(add);
1040
+ program.command("add <component>").description("Add a component to your project").option(
1041
+ "--ref <branch>",
1042
+ "read the component from this branch of the registry instead of the default"
1043
+ ).option(
1044
+ "--lang <code>",
1045
+ "install the component's text in this language instead of English (for example: he)"
1046
+ ).action(
1047
+ (component, options) => add(component, { ref: options.ref, lang: options.lang })
1048
+ );
626
1049
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sublay/cli",
3
- "version": "7.0.1",
3
+ "version": "7.1.0",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sublay, maintained by Yanay Tsabary",
@@ -43,13 +43,15 @@
43
43
  "fs-extra": "^11.3.4",
44
44
  "node-fetch": "^3.3.2",
45
45
  "ora": "^9.3.0",
46
- "prompts": "^2.4.2"
46
+ "prompts": "^2.4.2",
47
+ "semver": "^7.7.3"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/babel__core": "^7.20.5",
50
51
  "@types/fs-extra": "^11.0.4",
51
52
  "@types/node": "^25.9.2",
52
53
  "@types/prompts": "^2.4.9",
54
+ "@types/semver": "^7.7.1",
53
55
  "tsup": "^8.5.1",
54
56
  "typescript": "^6.0.3",
55
57
  "vitest": "^1.6.1"