@embeddables/cli 0.5.1 → 0.6.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.
package/dist/cli.js CHANGED
@@ -30,7 +30,7 @@ program
30
30
  });
31
31
  program
32
32
  .command('build')
33
- .requiredOption('-i, --id <id>', 'Embeddable ID')
33
+ .option('-i, --id <id>', 'Embeddable ID (will prompt if not provided)')
34
34
  .option('-p, --pages <glob>', 'Pages glob')
35
35
  .option('-o, --out <path>', 'Output json path')
36
36
  .option('--pageKeyFrom <mode>', 'filename|export', 'filename')
@@ -1 +1 @@
1
- {"version":3,"file":"branch.d.ts","sourceRoot":"","sources":["../../src/commands/branch.ts"],"names":[],"mappings":"AAKA,wBAAsB,SAAS,CAAC,IAAI,EAAE;IAAE,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,iBAgDpD"}
1
+ {"version":3,"file":"branch.d.ts","sourceRoot":"","sources":["../../src/commands/branch.ts"],"names":[],"mappings":"AAKA,wBAAsB,SAAS,CAAC,IAAI,EAAE;IAAE,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,iBAoDpD"}
@@ -41,6 +41,10 @@ export async function runBranch(opts) {
41
41
  else {
42
42
  console.log(pc.cyan(`Switching to branch: ${selectedBranch.name}...`));
43
43
  console.log('');
44
- await runPull({ id: embeddableId, branch: selectedBranch.id });
44
+ await runPull({
45
+ id: embeddableId,
46
+ branch: selectedBranch.id,
47
+ branchName: selectedBranch.name,
48
+ });
45
49
  }
46
50
  }
@@ -1,5 +1,5 @@
1
1
  export declare function runBuild(opts: {
2
- id: string;
2
+ id?: string;
3
3
  pages?: string;
4
4
  out?: string;
5
5
  pageKeyFrom: 'filename' | 'export';
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/commands/build.ts"],"names":[],"mappings":"AAIA,wBAAsB,QAAQ,CAAC,IAAI,EAAE;IACnC,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,UAAU,GAAG,QAAQ,CAAA;CACnC,iBAmBA"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/commands/build.ts"],"names":[],"mappings":"AAKA,wBAAsB,QAAQ,CAAC,IAAI,EAAE;IACnC,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,UAAU,GAAG,QAAQ,CAAA;CACnC,iBA4BA"}
@@ -1,8 +1,18 @@
1
1
  import path from 'node:path';
2
2
  import { compileAllPages } from '../compiler/index.js';
3
3
  import { formatError } from '../compiler/errors.js';
4
+ import { promptForLocalEmbeddable } from '../prompts/index.js';
4
5
  export async function runBuild(opts) {
5
- const embeddableId = opts.id;
6
+ let embeddableId = opts.id;
7
+ if (!embeddableId) {
8
+ const selected = await promptForLocalEmbeddable({
9
+ message: 'Select an embeddable to build:',
10
+ });
11
+ if (!selected) {
12
+ process.exit(1);
13
+ }
14
+ embeddableId = selected;
15
+ }
6
16
  const pagesGlob = opts.pages || `embeddables/${embeddableId}/pages/**/*.page.tsx`;
7
17
  const outPath = opts.out || path.join('embeddables', embeddableId, '.generated', 'embeddable.json');
8
18
  const stylesDir = path.join('embeddables', embeddableId, 'styles');
@@ -1,7 +1,9 @@
1
- export declare function runPull(opts: {
1
+ export type RunPullOptions = {
2
2
  id?: string;
3
3
  out?: string;
4
4
  branch?: string;
5
+ branchName?: string;
5
6
  fix?: boolean;
6
- }): Promise<void>;
7
+ };
8
+ export declare function runPull(opts: RunPullOptions): Promise<void>;
7
9
  //# sourceMappingURL=pull.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pull.d.ts","sourceRoot":"","sources":["../../src/commands/pull.ts"],"names":[],"mappings":"AAUA,wBAAsB,OAAO,CAAC,IAAI,EAAE;IAAE,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,OAAO,CAAA;CAAE,iBAwNhG"}
1
+ {"version":3,"file":"pull.d.ts","sourceRoot":"","sources":["../../src/commands/pull.ts"],"names":[],"mappings":"AAyEA,MAAM,MAAM,cAAc,GAAG;IAC3B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,GAAG,CAAC,EAAE,OAAO,CAAA;CACd,CAAA;AAED,wBAAsB,OAAO,CAAC,IAAI,EAAE,cAAc,iBAkPjD"}
@@ -6,6 +6,69 @@ import { reverseCompile } from '../compiler/reverse.js';
6
6
  import { getAccessToken, isLoggedIn } from '../auth/index.js';
7
7
  import { getProjectId, writeProjectConfig } from '../config/index.js';
8
8
  import { promptForProject, promptForEmbeddable, fetchEmbeddableMetadata } from '../prompts/index.js';
9
+ /** Slug for branch name/id for use in filenames (e.g. "my branch" -> "my_branch"). */
10
+ function slugForBranch(nameOrId) {
11
+ return String(nameOrId).replace(/[^a-zA-Z0-9_.-]/g, '_').replace(/_+/g, '_') || 'main';
12
+ }
13
+ /** Versioned embeddable filename: embeddable-{branchSlug}@{version}.json */
14
+ function getVersionedBasename(version, branchSlug) {
15
+ const v = typeof version === 'number' ? version : String(version);
16
+ return `embeddable-${branchSlug}@${v}.json`;
17
+ }
18
+ /** Read current _branch_id and _branch_name from config (set when on a branch via `embeddables branch`). */
19
+ function getCurrentBranchFromConfig(embeddableId) {
20
+ const configPath = path.join('embeddables', embeddableId, 'config.json');
21
+ if (!fs.existsSync(configPath))
22
+ return null;
23
+ try {
24
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
25
+ const branchId = config._branch_id;
26
+ if (typeof branchId !== 'string' || !branchId)
27
+ return null;
28
+ const branchName = config._branch_name;
29
+ return { branchId, branchName: typeof branchName === 'string' ? branchName : undefined };
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /** Write _version, _branch_id, _branch_name to config.json. Called early and in finally so it always runs. */
36
+ function writePullMetadataToConfig(embeddableId, version, branch, branchName) {
37
+ const configPath = path.join('embeddables', embeddableId, 'config.json');
38
+ let config = {};
39
+ try {
40
+ if (fs.existsSync(configPath)) {
41
+ try {
42
+ const content = fs.readFileSync(configPath, 'utf8');
43
+ config = JSON.parse(content);
44
+ }
45
+ catch {
46
+ /* use empty config */
47
+ }
48
+ }
49
+ if (version != null) {
50
+ const n = typeof version === 'number' ? version : parseInt(String(version), 10);
51
+ if (!isNaN(n))
52
+ config._version = n;
53
+ }
54
+ if (branch) {
55
+ config._branch_id = branch;
56
+ if (branchName)
57
+ config._branch_name = branchName;
58
+ else
59
+ delete config._branch_name;
60
+ }
61
+ else {
62
+ delete config._branch_id;
63
+ delete config._branch_name;
64
+ }
65
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
66
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
67
+ }
68
+ catch (err) {
69
+ console.warn(pc.yellow(`Could not write config.json: ${err instanceof Error ? err.message : err}`));
70
+ }
71
+ }
9
72
  export async function runPull(opts) {
10
73
  let embeddableId = opts.id;
11
74
  // If no ID provided, try to get it interactively
@@ -34,7 +97,7 @@ export async function runPull(opts) {
34
97
  project_id: projectId,
35
98
  project_name: selectedProject.title || undefined,
36
99
  });
37
- console.log(pc.green(`✓ Saved project to embeddables.json`));
100
+ console.log(pc.green(`✓ Wrote project config to embeddables.json`));
38
101
  console.log('');
39
102
  }
40
103
  console.log(pc.cyan('Fetching embeddables from project...'));
@@ -47,12 +110,23 @@ export async function runPull(opts) {
47
110
  embeddableId = selected;
48
111
  console.log('');
49
112
  }
113
+ // Stay on current branch when no --branch: use config's _branch_id if set
114
+ const currentFromConfig = opts.branch == null ? getCurrentBranchFromConfig(embeddableId) : null;
115
+ const effectiveBranch = opts.branch ?? currentFromConfig?.branchId;
116
+ const effectiveBranchName = opts.branchName ?? currentFromConfig?.branchName;
50
117
  let url = `https://engine.embeddables.com/${embeddableId}?version=latest`;
51
- if (opts.branch) {
52
- url += `&embeddable_branch=${opts.branch}`;
118
+ if (effectiveBranch) {
119
+ url += `&embeddable_branch=${effectiveBranch}`;
53
120
  }
54
121
  const outPath = opts.out || path.join('embeddables', embeddableId, '.generated', 'embeddable.json');
122
+ const branchLabel = effectiveBranch
123
+ ? effectiveBranchName
124
+ ? `${effectiveBranchName} (${effectiveBranch})`
125
+ : effectiveBranch
126
+ : 'main';
127
+ console.log(pc.cyan(`Pulling branch: ${pc.bold(branchLabel)}`));
55
128
  console.log(`Fetching embeddable from ${url}...`);
129
+ let pullVersion;
56
130
  try {
57
131
  // Add authentication header if available
58
132
  const accessToken = getAccessToken();
@@ -75,24 +149,28 @@ export async function runPull(opts) {
75
149
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
76
150
  const flowJson = JSON.stringify(flow, null, 2);
77
151
  fs.writeFileSync(outPath, flowJson, 'utf8');
78
- console.log(pc.cyan(`✓ Saved embeddable JSON to ${outPath}`));
79
- // Also save version-specific file (e.g. embeddable-v135.json) when version is present in response
80
- const version = data.version ?? data.embeddable_version ?? flow.version;
152
+ console.log(pc.cyan(`✓ Wrote embeddable JSON to ${outPath}`));
153
+ // Also save version-specific file (e.g. embeddable-main@135.json or embeddable-my_branch@135.json)
154
+ pullVersion =
155
+ data.version ?? data.embeddable_version ?? flow.version;
156
+ const version = pullVersion;
157
+ const branchSlug = slugForBranch(effectiveBranchName ?? effectiveBranch ?? 'main');
81
158
  if (version != null) {
82
159
  const versionStr = typeof version === 'string' ? version : String(version);
83
- const versionLabel = versionStr.startsWith('v') ? versionStr : `v${versionStr}`;
84
- const versionedBasename = `embeddable-${versionLabel}.json`;
160
+ const versionedBasename = getVersionedBasename(versionStr, branchSlug);
85
161
  const versionedPath = path.join(path.dirname(outPath), versionedBasename);
86
162
  fs.writeFileSync(versionedPath, flowJson, 'utf8');
87
- console.log(pc.cyan(`✓ Saved embeddable JSON to ${versionedPath}`));
163
+ console.log(pc.cyan(`✓ Wrote versioned embeddable JSON to ${versionedPath}`));
88
164
  }
165
+ // Persist _version and _branch_id in config.json immediately so they survive reverseCompile
166
+ writePullMetadataToConfig(embeddableId, version, effectiveBranch, effectiveBranchName);
89
167
  // Fetch and save flow metadata from DB (title, archived, created_at)
90
168
  const metadata = await fetchEmbeddableMetadata(embeddableId);
91
169
  if (metadata) {
92
170
  const metadataPath = path.join('embeddables', embeddableId, 'metadata.json');
93
171
  fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
94
172
  fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2) + '\n', 'utf8');
95
- console.log(pc.cyan(`✓ Saved flow metadata to ${metadataPath}`));
173
+ console.log(pc.cyan(`✓ Wrote flow metadata to ${metadataPath}`));
96
174
  }
97
175
  // Clear existing pages, styles, computed-fields, actions, and global-components before generating new ones
98
176
  const pagesDir = path.join('embeddables', embeddableId, 'pages');
@@ -137,9 +215,16 @@ export async function runPull(opts) {
137
215
  }
138
216
  console.log(`${pc.gray(`Cleared ${existingGlobalComponents.length} existing global component(s)`)}`);
139
217
  }
140
- // Run reverse compiler
218
+ // Run reverse compiler (pass pullMetadata so config.json gets _version and _branch_id even on fix retry)
219
+ const versionNum = version != null && !isNaN(Number(version)) ? Number(version) : undefined;
220
+ const pullMetadata = {
221
+ version: versionNum,
222
+ branchId: effectiveBranch,
223
+ branchName: effectiveBranchName,
224
+ };
225
+ let usedFix = opts.fix;
141
226
  try {
142
- await reverseCompile(flow, embeddableId, { fix: opts.fix });
227
+ await reverseCompile(flow, embeddableId, { fix: opts.fix, pullMetadata });
143
228
  }
144
229
  catch (compileError) {
145
230
  // If fix mode wasn't already enabled, offer to retry with fix mode
@@ -159,10 +244,11 @@ export async function runPull(opts) {
159
244
  },
160
245
  });
161
246
  if (response.fix) {
247
+ usedFix = true;
162
248
  console.log('');
163
249
  console.log(pc.cyan('Retrying with auto-fix enabled...'));
164
250
  console.log('');
165
- await reverseCompile(flow, embeddableId, { fix: true });
251
+ await reverseCompile(flow, embeddableId, { fix: true, pullMetadata });
166
252
  }
167
253
  else {
168
254
  process.exit(1);
@@ -172,22 +258,17 @@ export async function runPull(opts) {
172
258
  throw compileError;
173
259
  }
174
260
  }
175
- // Store version number in config.json so save knows the base version
176
- if (version != null) {
177
- const versionNumber = typeof version === 'number' ? version : parseInt(String(version), 10);
178
- if (!isNaN(versionNumber)) {
179
- const configPath = path.join('embeddables', embeddableId, 'config.json');
180
- if (fs.existsSync(configPath)) {
181
- try {
182
- const configContent = fs.readFileSync(configPath, 'utf8');
183
- const config = JSON.parse(configContent);
184
- config._version = versionNumber;
185
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
186
- }
187
- catch {
188
- // Ignore errors updating config.json - versioned files are a fallback
189
- }
190
- }
261
+ // Persist mutated flow when fixes were applied (e.g. parent_key -> parent_id)
262
+ if (usedFix) {
263
+ const flowJson = JSON.stringify(flow, null, 2);
264
+ fs.writeFileSync(outPath, flowJson, 'utf8');
265
+ console.log(pc.cyan(`✓ Wrote embeddable JSON to ${outPath} (with fixes applied)`));
266
+ if (version != null) {
267
+ const versionStr = typeof version === 'string' ? version : String(version);
268
+ const versionedBasename = getVersionedBasename(versionStr, branchSlug);
269
+ const versionedPath = path.join(path.dirname(outPath), versionedBasename);
270
+ fs.writeFileSync(versionedPath, flowJson, 'utf8');
271
+ console.log(pc.cyan(`✓ Wrote versioned embeddable JSON to ${versionedPath} (with fixes applied)`));
191
272
  }
192
273
  }
193
274
  }
@@ -195,4 +276,10 @@ export async function runPull(opts) {
195
276
  console.error('Error pulling embeddable:', error);
196
277
  process.exit(1);
197
278
  }
279
+ finally {
280
+ // Always persist _version and _branch_id once we've fetched (so config is correct even if reverseCompile failed or was retried with fix)
281
+ if (embeddableId != null && pullVersion !== undefined) {
282
+ writePullMetadataToConfig(embeddableId, pullVersion, effectiveBranch, effectiveBranchName);
283
+ }
284
+ }
198
285
  }
@@ -1 +1 @@
1
- {"version":3,"file":"save.d.ts","sourceRoot":"","sources":["../../src/commands/save.ts"],"names":[],"mappings":"AAoJA,wBAAsB,OAAO,CAAC,IAAI,EAAE;IAClC,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,iBAoBA"}
1
+ {"version":3,"file":"save.d.ts","sourceRoot":"","sources":["../../src/commands/save.ts"],"names":[],"mappings":"AA+OA,wBAAsB,OAAO,CAAC,IAAI,EAAE;IAClC,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,iBAoBA"}
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import pc from 'picocolors';
4
4
  import prompts from 'prompts';
5
- import { getAccessToken, isLoggedIn } from '../auth/index.js';
5
+ import { getAccessToken, getAuthenticatedSupabaseClient, isLoggedIn } from '../auth/index.js';
6
6
  import { getProjectId, writeProjectConfig } from '../config/index.js';
7
7
  import { compileAllPages } from '../compiler/index.js';
8
8
  import { formatError } from '../compiler/errors.js';
@@ -69,6 +69,45 @@ function getVersionFromConfig(embeddableId) {
69
69
  }
70
70
  return null;
71
71
  }
72
+ /**
73
+ * Read `_branch_id` from config.json for the given embeddable (set when on a branch via `embeddables branch`).
74
+ */
75
+ function getBranchFromConfig(embeddableId) {
76
+ const configPath = path.join('embeddables', embeddableId, 'config.json');
77
+ if (!fs.existsSync(configPath)) {
78
+ return null;
79
+ }
80
+ try {
81
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
82
+ if (typeof config._branch_id === 'string' && config._branch_id) {
83
+ return config._branch_id;
84
+ }
85
+ }
86
+ catch {
87
+ // Ignore parse errors
88
+ }
89
+ return null;
90
+ }
91
+ /** Slug for branch name/id for versioned filenames (e.g. "my branch" -> "my_branch"). */
92
+ function slugForBranch(nameOrId) {
93
+ return String(nameOrId).replace(/[^a-zA-Z0-9_.-]/g, '_').replace(/_+/g, '_') || 'main';
94
+ }
95
+ /** Get branch slug from config (_branch_name preferred, else _branch_id, else main). */
96
+ function getBranchSlugFromConfig(embeddableId) {
97
+ const configPath = path.join('embeddables', embeddableId, 'config.json');
98
+ if (!fs.existsSync(configPath))
99
+ return 'main';
100
+ try {
101
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
102
+ const name = config._branch_name ?? config._branch_id;
103
+ if (typeof name === 'string' && name)
104
+ return slugForBranch(name);
105
+ }
106
+ catch {
107
+ /* ignore */
108
+ }
109
+ return 'main';
110
+ }
72
111
  /**
73
112
  * Update `_version` in config.json for the given embeddable.
74
113
  */
@@ -87,7 +126,7 @@ function setVersionInConfig(embeddableId, version) {
87
126
  }
88
127
  }
89
128
  /**
90
- * Scan the .generated/ directory for versioned files (embeddable-v*.json)
129
+ * Scan the .generated/ directory for versioned files (embeddable-v*.json or embeddable-*@*.json)
91
130
  * and return the highest version number found.
92
131
  */
93
132
  function getLatestVersionFromFiles(generatedDir) {
@@ -95,10 +134,11 @@ function getLatestVersionFromFiles(generatedDir) {
95
134
  return null;
96
135
  }
97
136
  const files = fs.readdirSync(generatedDir);
98
- const versionPattern = /^embeddable-v(\d+)\.json$/;
137
+ const legacyPattern = /^embeddable-v(\d+)\.json$/;
138
+ const branchPattern = /^embeddable-[^@]+@(\d+)\.json$/;
99
139
  let maxVersion = null;
100
140
  for (const file of files) {
101
- const match = file.match(versionPattern);
141
+ const match = file.match(legacyPattern) ?? file.match(branchPattern);
102
142
  if (match) {
103
143
  const version = parseInt(match[1], 10);
104
144
  if (maxVersion === null || version > maxVersion) {
@@ -108,6 +148,41 @@ function getLatestVersionFromFiles(generatedDir) {
108
148
  }
109
149
  return maxVersion;
110
150
  }
151
+ /**
152
+ * Fetch active drafts from other users on the same version (status=DRAFT, not saved/discarded).
153
+ * Used to warn before saving that others may have unsaved edits.
154
+ */
155
+ async function fetchOtherUsersDrafts(supabase, flowId, versionNumber, branchId, currentUserId) {
156
+ try {
157
+ let query = supabase
158
+ .from('flow_versions')
159
+ .select('id, author_id, author_name')
160
+ .eq('flow_id', flowId)
161
+ .eq('status', 'DRAFT')
162
+ .eq('version_number', versionNumber)
163
+ .not('draft_saved', 'is', true)
164
+ .not('draft_discarded', 'is', true)
165
+ .neq('author_id', currentUserId);
166
+ if (branchId === null) {
167
+ query = query.is('branch_id', null);
168
+ }
169
+ else {
170
+ query = query.eq('branch_id', branchId);
171
+ }
172
+ const { data, error } = await query;
173
+ if (error) {
174
+ return [];
175
+ }
176
+ return (data || []).map((row) => ({
177
+ id: row.id,
178
+ author_id: row.author_id,
179
+ author_name: row.author_name ?? null,
180
+ }));
181
+ }
182
+ catch {
183
+ return [];
184
+ }
185
+ }
111
186
  export async function runSave(opts) {
112
187
  try {
113
188
  await runSaveInner(opts);
@@ -158,6 +233,8 @@ async function runSaveInner(opts) {
158
233
  embeddableId = selected;
159
234
  console.log('');
160
235
  }
236
+ // Resolve branch: explicit -b flag wins, otherwise use current branch from config (set by `embeddables branch`)
237
+ const effectiveBranch = opts.branch ?? getBranchFromConfig(embeddableId) ?? undefined;
161
238
  // 4. Get project ID (from config or interactive prompt)
162
239
  let projectId = getProjectId();
163
240
  if (!projectId) {
@@ -242,6 +319,39 @@ async function runSaveInner(opts) {
242
319
  }
243
320
  fromVersionNumber = detectedVersion;
244
321
  }
322
+ // 7b. Check for other users' drafts on this version; warn and optionally abort
323
+ const supabase = await getAuthenticatedSupabaseClient();
324
+ if (supabase) {
325
+ const { data: { user }, } = await supabase.auth.getUser();
326
+ const currentUserId = user?.id;
327
+ if (currentUserId) {
328
+ const branchIdForDrafts = effectiveBranch ?? null;
329
+ const otherDrafts = await fetchOtherUsersDrafts(supabase, embeddableId, fromVersionNumber, branchIdForDrafts, currentUserId);
330
+ if (otherDrafts.length > 0) {
331
+ const names = otherDrafts
332
+ .map((d) => d.author_name?.trim() || d.author_id || 'Someone')
333
+ .filter((n, i, a) => a.indexOf(n) === i);
334
+ const namesText = names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
335
+ console.log('');
336
+ console.warn(pc.yellow(`⚠ ${namesText} ${names.length === 1 ? 'has' : 'have'} unsaved edits on version ${fromVersionNumber}. Saving may cause conflicts.`));
337
+ const { proceed } = await prompts({
338
+ type: 'confirm',
339
+ name: 'proceed',
340
+ message: 'Save anyway?',
341
+ initial: false,
342
+ }, {
343
+ onCancel: () => {
344
+ process.exit(1);
345
+ },
346
+ });
347
+ if (!proceed) {
348
+ console.log(pc.gray('Save cancelled.'));
349
+ process.exit(0);
350
+ }
351
+ console.log('');
352
+ }
353
+ }
354
+ }
245
355
  // 8. POST to save-version API
246
356
  console.log(pc.cyan(`Saving embeddable (based on v${fromVersionNumber})...`));
247
357
  const body = {
@@ -253,8 +363,8 @@ async function runSaveInner(opts) {
253
363
  if (opts.label) {
254
364
  body.label = opts.label;
255
365
  }
256
- if (opts.branch) {
257
- body.branchId = opts.branch;
366
+ if (effectiveBranch) {
367
+ body.branchId = effectiveBranch;
258
368
  }
259
369
  const url = `${WEB_APP_BASE_URL}/api/embeddables/save-version`;
260
370
  const headers = {
@@ -340,7 +450,8 @@ async function runSaveInner(opts) {
340
450
  const { newVersionNumber } = forceResult.data;
341
451
  console.log(pc.green(`✓ Saved as version ${newVersionNumber}`));
342
452
  setVersionInConfig(embeddableId, newVersionNumber);
343
- const versionedPath = path.join(generatedDir, `embeddable-v${newVersionNumber}.json`);
453
+ const branchSlug = getBranchSlugFromConfig(embeddableId);
454
+ const versionedPath = path.join(generatedDir, `embeddable-${branchSlug}@${newVersionNumber}.json`);
344
455
  fs.mkdirSync(generatedDir, { recursive: true });
345
456
  fs.writeFileSync(versionedPath, jsonContent, 'utf8');
346
457
  console.log(pc.cyan(`✓ Saved version file to ${versionedPath}`));
@@ -358,8 +469,9 @@ async function runSaveInner(opts) {
358
469
  console.log(pc.green(`✓ Saved as version ${newVersionNumber}`));
359
470
  // Update _version in config.json so future saves know the base version
360
471
  setVersionInConfig(embeddableId, newVersionNumber);
361
- // Also save the versioned file to .generated/ as a snapshot
362
- const versionedPath = path.join(generatedDir, `embeddable-v${newVersionNumber}.json`);
472
+ // Also save the versioned file to .generated/ as a snapshot (embeddable-{branch}@{version}.json)
473
+ const branchSlug = getBranchSlugFromConfig(embeddableId);
474
+ const versionedPath = path.join(generatedDir, `embeddable-${branchSlug}@${newVersionNumber}.json`);
363
475
  fs.mkdirSync(generatedDir, { recursive: true });
364
476
  fs.writeFileSync(versionedPath, jsonContent, 'utf8');
365
477
  console.log(pc.cyan(`✓ Saved version file to ${versionedPath}`));
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/compiler/index.ts"],"names":[],"mappings":"AA+CA;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CA2DlF;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE;IAC1C,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,UAAU,GAAG,QAAQ,CAAA;IAClC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,iBAiXA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/compiler/index.ts"],"names":[],"mappings":"AA+CA;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CA2DlF;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE;IAC1C,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,UAAU,GAAG,QAAQ,CAAA;IAClC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,iBAoXA"}
@@ -333,6 +333,10 @@ export async function compileAllPages(opts) {
333
333
  // Skip _version - this is CLI metadata (tracked version number), not part of the embeddable
334
334
  continue;
335
335
  }
336
+ else if (key === '_branch_id' || key === '_branch_name') {
337
+ // Skip _branch_id / _branch_name - CLI metadata (current branch), not part of the embeddable
338
+ continue;
339
+ }
336
340
  else if (key === 'computedFields') {
337
341
  // Replace computedFields with loaded computedFields (which include code)
338
342
  if (computedFields.length > 0) {
@@ -9,6 +9,11 @@ export declare function reverseCompile(embeddable: {
9
9
  [key: string]: any;
10
10
  }, embeddableId: string, opts?: {
11
11
  fix?: boolean;
12
+ pullMetadata?: {
13
+ version?: number;
14
+ branchId?: string;
15
+ branchName?: string;
16
+ };
12
17
  }): Promise<void>;
13
18
  /**
14
19
  * Sanitizes a string to be safe for use as a filename.
@@ -1 +1 @@
1
- {"version":3,"file":"reverse.d.ts","sourceRoot":"","sources":["../../src/compiler/reverse.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,QAAQ,EAAiB,MAAM,YAAY,CAAA;AAihBzD,wBAAsB,cAAc,CAClC,UAAU,EAAE;IACV,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,QAAQ,EAAE,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC5B,cAAc,CAAC,EAAE,GAAG,EAAE,CAAA;IACtB,WAAW,CAAC,EAAE,GAAG,EAAE,CAAA;IACnB,UAAU,CAAC,EAAE,GAAG,EAAE,CAAA;IAClB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB,EACD,YAAY,EAAE,MAAM,EACpB,IAAI,CAAC,EAAE;IAAE,GAAG,CAAC,EAAE,OAAO,CAAA;CAAE,iBAmDzB;AA2yCD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAKpD"}
1
+ {"version":3,"file":"reverse.d.ts","sourceRoot":"","sources":["../../src/compiler/reverse.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,QAAQ,EAAiB,MAAM,YAAY,CAAA;AAqlBzD,wBAAsB,cAAc,CAClC,UAAU,EAAE;IACV,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,QAAQ,EAAE,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC5B,cAAc,CAAC,EAAE,GAAG,EAAE,CAAA;IACtB,WAAW,CAAC,EAAE,GAAG,EAAE,CAAA;IACnB,UAAU,CAAC,EAAE,GAAG,EAAE,CAAA;IAClB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB,EACD,YAAY,EAAE,MAAM,EACpB,IAAI,CAAC,EAAE;IACL,GAAG,CAAC,EAAE,OAAO,CAAA;IACb,YAAY,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAC5E,iBAmFF;AA+0CD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAKpD"}
@@ -284,6 +284,7 @@ function validateComponentProps(component, validProps) {
284
284
  'tags',
285
285
  'type',
286
286
  'parent_id',
287
+ 'parent_key',
287
288
  'buttons',
288
289
  ...IGNORED_COMPONENT_PROPERTIES,
289
290
  ]);
@@ -424,6 +425,61 @@ function checkAndFixDuplicateIds(pages, fix) {
424
425
  }
425
426
  return idMapping;
426
427
  }
428
+ /**
429
+ * Fixes deprecated parent_key: resolves to parent_id using first component with matching key,
430
+ * removes parent_key, and removes components that still have no parent_id after resolution.
431
+ * Only runs when fix is enabled.
432
+ */
433
+ function fixParentKeyDeprecation(components, fix) {
434
+ if (!fix || components.length === 0)
435
+ return;
436
+ // Build key -> id map (first occurrence wins, exclude ignored types)
437
+ const keyToId = new Map();
438
+ for (const comp of components) {
439
+ if (comp.key &&
440
+ comp.id &&
441
+ !keyToId.has(comp.key) &&
442
+ comp.type &&
443
+ !IGNORED_COMPONENT_TYPES.has(comp.type)) {
444
+ keyToId.set(comp.key, comp.id);
445
+ }
446
+ }
447
+ // Resolve parent_key -> parent_id, remove parent_key, track components to remove
448
+ const toRemove = new Map(); // id -> parent_key for warning
449
+ for (const comp of components) {
450
+ const parentKey = comp.parent_key;
451
+ if (!parentKey)
452
+ continue;
453
+ if (!comp.parent_id) {
454
+ const resolvedId = keyToId.get(parentKey);
455
+ if (resolvedId) {
456
+ comp.parent_id = resolvedId;
457
+ console.warn(`Fixed parent_key on component (id: ${comp.id}, key: ${comp.key}) – resolved parent_key "${parentKey}" to parent_id.`);
458
+ }
459
+ else {
460
+ toRemove.set(comp.id, parentKey);
461
+ }
462
+ }
463
+ else {
464
+ console.warn(`Fixed parent_key on component (id: ${comp.id}, key: ${comp.key}) – removed deprecated parent_key (already has parent_id).`);
465
+ }
466
+ delete comp.parent_key;
467
+ }
468
+ // Remove components that couldn't resolve parent
469
+ if (toRemove.size > 0) {
470
+ let i = 0;
471
+ while (i < components.length) {
472
+ const comp = components[i];
473
+ if (toRemove.has(comp.id)) {
474
+ console.warn(`Removed component (id: ${comp.id}, key: ${comp.key}) – parent_key "${toRemove.get(comp.id)}" not found, no parent_id.`);
475
+ components.splice(i, 1);
476
+ }
477
+ else {
478
+ i++;
479
+ }
480
+ }
481
+ }
482
+ }
427
483
  /**
428
484
  * Applies the ID mapping to all pages, updating component IDs and button IDs.
429
485
  * The mapping uses occurrence keys (pageKey:componentIndex or pageKey:componentIndex:buttonIndex).
@@ -461,14 +517,43 @@ function applyIdMapping(pages, idMapping) {
461
517
  }
462
518
  }
463
519
  }
520
+ function hasDeprecatedParentKey(components) {
521
+ return components.some((comp) => comp.parent_key != null);
522
+ }
464
523
  export async function reverseCompile(embeddable, embeddableId, opts) {
465
524
  const fix = opts?.fix ?? false;
525
+ const pullMetadata = opts?.pullMetadata;
526
+ // When fix is disabled, throw on deprecated parent_key so user gets interactive retry prompt
527
+ if (!fix) {
528
+ for (const page of embeddable.pages) {
529
+ if (hasDeprecatedParentKey(page.components)) {
530
+ const count = page.components.filter((c) => c.parent_key != null).length;
531
+ throw new Error(`Found deprecated parent_key on ${count} component(s) in page "${page.key}". Run with --fix to resolve.`);
532
+ }
533
+ }
534
+ if (embeddable.components && Array.isArray(embeddable.components)) {
535
+ if (hasDeprecatedParentKey(embeddable.components)) {
536
+ const count = embeddable.components.filter((c) => c.parent_key != null).length;
537
+ throw new Error(`Found deprecated parent_key on ${count} global component(s). Run with --fix to resolve.`);
538
+ }
539
+ }
540
+ }
466
541
  // Check for duplicate IDs across all pages and create a mapping
467
542
  const idMapping = checkAndFixDuplicateIds(embeddable.pages, fix);
468
543
  // Apply ID mapping to all pages
469
544
  if (idMapping.size > 0) {
470
545
  applyIdMapping(embeddable.pages, idMapping);
471
546
  }
547
+ // Fix deprecated parent_key (resolve to parent_id, remove parent_key, drop orphans).
548
+ // Must run before extractGlobalComponents so resolved parent_id avoids "must have _location since it has no parent_id" errors.
549
+ if (fix) {
550
+ for (const page of embeddable.pages) {
551
+ fixParentKeyDeprecation(page.components, fix);
552
+ }
553
+ if (embeddable.components && Array.isArray(embeddable.components)) {
554
+ fixParentKeyDeprecation(embeddable.components, fix);
555
+ }
556
+ }
472
557
  // Generate TSX pages
473
558
  for (const page of embeddable.pages) {
474
559
  await generatePageFile(page, embeddableId, fix);
@@ -478,7 +563,7 @@ export async function reverseCompile(embeddable, embeddableId, opts) {
478
563
  await generateStylesFile(embeddable.styles, embeddableId);
479
564
  }
480
565
  // Generate config.json
481
- await generateConfigFile(embeddable, embeddableId);
566
+ await generateConfigFile(embeddable, embeddableId, pullMetadata);
482
567
  // Extract computedFields to JS files
483
568
  if (embeddable.computedFields && embeddable.computedFields.length > 0) {
484
569
  await extractComputedFields(embeddable.computedFields, embeddableId);
@@ -758,6 +843,7 @@ function generateJSX(node, indent = 4, pageKey, componentId, nameMap) {
758
843
  'tags',
759
844
  'type',
760
845
  'parent_id',
846
+ 'parent_key',
761
847
  'buttons',
762
848
  '_location',
763
849
  ...IGNORED_COMPONENT_PROPERTIES,
@@ -1347,9 +1433,27 @@ function escapeStringForJS(str) {
1347
1433
  * This file controls page ordering and stores embeddable-level metadata.
1348
1434
  * Preserves the order of top-level properties from embeddable.json.
1349
1435
  */
1350
- async function generateConfigFile(embeddable, embeddableId) {
1436
+ async function generateConfigFile(embeddable, embeddableId, pullMetadata) {
1351
1437
  try {
1352
1438
  const configPath = path.join('embeddables', embeddableId, 'config.json');
1439
+ // CLI-only fields: from pull (when branching/saving) or from existing config file.
1440
+ let preservedVersion;
1441
+ let preservedBranchId;
1442
+ let preservedBranchName;
1443
+ if (fs.existsSync(configPath)) {
1444
+ try {
1445
+ const existing = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1446
+ if (typeof existing._version === 'number')
1447
+ preservedVersion = existing._version;
1448
+ if (typeof existing._branch_id === 'string' && existing._branch_id)
1449
+ preservedBranchId = existing._branch_id;
1450
+ if (typeof existing._branch_name === 'string' && existing._branch_name)
1451
+ preservedBranchName = existing._branch_name;
1452
+ }
1453
+ catch {
1454
+ /* ignore */
1455
+ }
1456
+ }
1353
1457
  // Preserve the order of top-level properties from embeddable
1354
1458
  const embeddableKeys = Object.keys(embeddable);
1355
1459
  // Extract page metadata and order (excluding components which are in TSX files)
@@ -1432,6 +1536,29 @@ async function generateConfigFile(embeddable, embeddableId) {
1432
1536
  if (!config.id) {
1433
1537
  config.id = embeddableId;
1434
1538
  }
1539
+ // Restore CLI-only fields: prefer pullMetadata (passed by pull/branch) so branch is never lost.
1540
+ const versionToWrite = pullMetadata?.version ?? preservedVersion;
1541
+ if (versionToWrite !== undefined)
1542
+ config._version = versionToWrite;
1543
+ if (pullMetadata !== undefined) {
1544
+ if (pullMetadata.branchId !== undefined) {
1545
+ config._branch_id = pullMetadata.branchId;
1546
+ if (pullMetadata.branchName !== undefined)
1547
+ config._branch_name = pullMetadata.branchName;
1548
+ else
1549
+ delete config._branch_name;
1550
+ }
1551
+ else {
1552
+ delete config._branch_id;
1553
+ delete config._branch_name;
1554
+ }
1555
+ }
1556
+ else {
1557
+ if (preservedBranchId !== undefined)
1558
+ config._branch_id = preservedBranchId;
1559
+ if (preservedBranchName !== undefined)
1560
+ config._branch_name = preservedBranchName;
1561
+ }
1435
1562
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
1436
1563
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
1437
1564
  console.log(`${pc.gray(`Generated ${configPath}`)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@embeddables/cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "embeddables": "./bin/embeddables.mjs"