create-interview-cockpit 0.30.0 → 0.31.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -236,18 +236,105 @@ function mapJobStatusToCheck(
236
236
  return s;
237
237
  }
238
238
 
239
- // Tiny grouped-by-folder list to keep the modal lean.
240
- function groupByFolder(paths: string[]): { folder: string; files: string[] }[] {
241
- const map = new Map<string, string[]>();
242
- for (const p of paths) {
243
- const idx = p.lastIndexOf("/");
244
- const folder = idx === -1 ? "" : p.slice(0, idx);
245
- if (!map.has(folder)) map.set(folder, []);
246
- map.get(folder)!.push(p);
239
+ interface FileTreeFileNode {
240
+ type: "file";
241
+ name: string;
242
+ path: string;
243
+ }
244
+
245
+ interface FileTreeFolderNode {
246
+ type: "folder";
247
+ displayName: string;
248
+ path: string;
249
+ children: FileTreeNode[];
250
+ }
251
+
252
+ type FileTreeNode = FileTreeFileNode | FileTreeFolderNode;
253
+
254
+ interface MutableFileTreeFolder {
255
+ name: string;
256
+ path: string;
257
+ files: FileTreeFileNode[];
258
+ folders: Map<string, MutableFileTreeFolder>;
259
+ }
260
+
261
+ function sortFileTreeNodes<T extends { name?: string; displayName?: string }>(
262
+ a: T,
263
+ b: T,
264
+ ) {
265
+ return (a.displayName ?? a.name ?? "").localeCompare(
266
+ b.displayName ?? b.name ?? "",
267
+ );
268
+ }
269
+
270
+ function getMutableFolderChildren(
271
+ folder: MutableFileTreeFolder,
272
+ ): FileTreeNode[] {
273
+ const files = [...folder.files].sort(sortFileTreeNodes);
274
+ const folders = Array.from(folder.folders.values())
275
+ .map(compactFileTreeFolder)
276
+ .sort(sortFileTreeNodes);
277
+ return [...files, ...folders];
278
+ }
279
+
280
+ function compactFileTreeFolder(
281
+ folder: MutableFileTreeFolder,
282
+ ): FileTreeFolderNode {
283
+ const names = [folder.name];
284
+ let current = folder;
285
+
286
+ while (current.files.length === 0 && current.folders.size === 1) {
287
+ const next = Array.from(current.folders.values())[0];
288
+ names.push(next.name);
289
+ current = next;
247
290
  }
248
- return Array.from(map.entries())
249
- .sort(([a], [b]) => a.localeCompare(b))
250
- .map(([folder, files]) => ({ folder, files: files.sort() }));
291
+
292
+ return {
293
+ type: "folder",
294
+ displayName: names.join("/"),
295
+ path: current.path,
296
+ children: getMutableFolderChildren(current),
297
+ };
298
+ }
299
+
300
+ function buildCompactFileTree(paths: string[]): FileTreeNode[] {
301
+ const root: MutableFileTreeFolder = {
302
+ name: "",
303
+ path: "",
304
+ files: [],
305
+ folders: new Map(),
306
+ };
307
+
308
+ for (const filePath of paths) {
309
+ const parts = filePath.split("/").filter(Boolean);
310
+ if (parts.length === 0) continue;
311
+
312
+ let current = root;
313
+ let currentPath = "";
314
+ for (let i = 0; i < parts.length - 1; i += 1) {
315
+ const name = parts[i];
316
+ currentPath = currentPath ? `${currentPath}/${name}` : name;
317
+ let next = current.folders.get(name);
318
+ if (!next) {
319
+ next = {
320
+ name,
321
+ path: currentPath,
322
+ files: [],
323
+ folders: new Map(),
324
+ };
325
+ current.folders.set(name, next);
326
+ }
327
+ current = next;
328
+ }
329
+
330
+ current.files.push({
331
+ type: "file",
332
+ name: parts[parts.length - 1],
333
+ path: filePath,
334
+ });
335
+ }
336
+
337
+ return getMutableFolderChildren(root);
251
338
  }
252
339
 
253
340
  // ─── Component ───────────────────────────────────────────────────────────
@@ -514,7 +601,7 @@ export default function GithubActionsLabModal() {
514
601
 
515
602
  // ── File operations ───────────────────────────────────────────────
516
603
  const fileOrder = useMemo(() => getGhaLabFileOrder(workspace), [workspace]);
517
- const grouped = useMemo(() => groupByFolder(fileOrder), [fileOrder]);
604
+ const fileTree = useMemo(() => buildCompactFileTree(fileOrder), [fileOrder]);
518
605
  const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
519
606
  () => new Set(),
520
607
  );
@@ -1706,10 +1793,161 @@ interface ImportMeta {
1706
1793
  minHeight: MIN_H,
1707
1794
  };
1708
1795
 
1796
+ const renderFileTreeNode = (node: FileTreeNode, depth: number) => {
1797
+ const paddingLeft = 6 + depth * 14;
1798
+
1799
+ if (node.type === "folder") {
1800
+ const collapsed = collapsedFolders.has(node.path);
1801
+ return (
1802
+ <div key={`folder:${node.path || node.displayName}`}>
1803
+ <button
1804
+ onClick={() => toggleFolder(node.path)}
1805
+ onDragOver={(e) => handleFolderDragOver(e, node.path)}
1806
+ onDragLeave={() =>
1807
+ setDragOverFolder((current) =>
1808
+ current === node.path ? null : current,
1809
+ )
1810
+ }
1811
+ onDrop={(e) => handleFolderDrop(e, node.path)}
1812
+ className="flex items-center gap-1 w-full pr-1 py-0.5 text-slate-400 hover:text-slate-200"
1813
+ style={{ paddingLeft }}
1814
+ title="Drop a file here to move it. Hold Option/Alt while dropping to copy."
1815
+ >
1816
+ {collapsed ? (
1817
+ <ChevronRight className="w-3 h-3 shrink-0" />
1818
+ ) : (
1819
+ <ChevronDown className="w-3 h-3 shrink-0" />
1820
+ )}
1821
+ <Folder className="w-3 h-3 shrink-0" />
1822
+ <span
1823
+ className={`truncate rounded px-1 ${
1824
+ dragOverFolder === node.path
1825
+ ? "bg-amber-500/15 text-amber-200"
1826
+ : ""
1827
+ }`}
1828
+ >
1829
+ {node.displayName}/
1830
+ </span>
1831
+ </button>
1832
+ {!collapsed &&
1833
+ node.children.map((child) => renderFileTreeNode(child, depth + 1))}
1834
+ </div>
1835
+ );
1836
+ }
1837
+
1838
+ const filePath = node.path;
1839
+ return (
1840
+ <div
1841
+ key={`file:${filePath}`}
1842
+ data-selected={selectedFiles.has(filePath)}
1843
+ draggable
1844
+ onDragStart={(e) => handleFileDragStart(e, filePath)}
1845
+ onDragEnd={() => {
1846
+ setDraggingFile(null);
1847
+ setDragOverFolder(null);
1848
+ }}
1849
+ className={`group relative flex items-center gap-1 pr-1 py-0.5 rounded cursor-pointer ${
1850
+ activeFile === filePath
1851
+ ? "bg-amber-500/15 text-amber-200"
1852
+ : selectedFiles.has(filePath)
1853
+ ? "bg-sky-500/10 text-sky-100 hover:bg-sky-500/15"
1854
+ : "text-slate-300 hover:bg-slate-800/40"
1855
+ }`}
1856
+ onClick={() => setActiveFile(filePath)}
1857
+ style={{ paddingLeft }}
1858
+ >
1859
+ {(selectMode || selectedFiles.has(filePath)) && (
1860
+ <input
1861
+ type="checkbox"
1862
+ checked={selectedFiles.has(filePath)}
1863
+ onClick={(e) => e.stopPropagation()}
1864
+ onChange={() => toggleFileSelection(filePath)}
1865
+ className="h-3 w-3 shrink-0 accent-amber-400"
1866
+ title="Select file"
1867
+ />
1868
+ )}
1869
+ <span className="truncate flex-1">{node.name}</span>
1870
+ <button
1871
+ onClick={(e) => {
1872
+ e.stopPropagation();
1873
+ setOpenFileMenu((current) =>
1874
+ current === filePath ? null : filePath,
1875
+ );
1876
+ setBulkMenuOpen(false);
1877
+ }}
1878
+ className="rounded px-1 text-slate-500 opacity-70 hover:bg-slate-800/70 hover:text-amber-200 group-hover:opacity-100"
1879
+ title="File actions"
1880
+ >
1881
+
1882
+ </button>
1883
+ {openFileMenu === filePath && (
1884
+ <div
1885
+ onClick={(e) => e.stopPropagation()}
1886
+ className="absolute right-1 top-6 z-40 w-40 overflow-hidden rounded-lg border border-slate-700 bg-slate-950 py-1 text-[11px] shadow-xl"
1887
+ >
1888
+ <button
1889
+ onClick={() => moveFile(filePath)}
1890
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1891
+ >
1892
+ <Pencil className="w-3 h-3 text-amber-300" />
1893
+ Move / rename…
1894
+ </button>
1895
+ <button
1896
+ onClick={() => copyFile(filePath)}
1897
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1898
+ >
1899
+ <Copy className="w-3 h-3 text-sky-300" />
1900
+ Copy to path…
1901
+ </button>
1902
+ <button
1903
+ onClick={() => {
1904
+ toggleFileSelection(filePath);
1905
+ setOpenFileMenu(null);
1906
+ }}
1907
+ className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1908
+ >
1909
+ <ListChecks className="w-3 h-3 text-amber-300" />
1910
+ {selectedFiles.has(filePath) ? "Deselect" : "Select"}
1911
+ </button>
1912
+ {selectedFileList.length > 1 && selectedFiles.has(filePath) && (
1913
+ <>
1914
+ <button
1915
+ onClick={() => {
1916
+ moveFilesToFolder(selectedFileList);
1917
+ setOpenFileMenu(null);
1918
+ }}
1919
+ className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1920
+ >
1921
+ Move selected…
1922
+ </button>
1923
+ <button
1924
+ onClick={() => {
1925
+ copyFilesToFolder(selectedFileList);
1926
+ setOpenFileMenu(null);
1927
+ }}
1928
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1929
+ >
1930
+ Copy selected…
1931
+ </button>
1932
+ </>
1933
+ )}
1934
+ <button
1935
+ onClick={() => deleteFile(filePath)}
1936
+ className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-red-300 hover:bg-red-500/10"
1937
+ >
1938
+ <Trash2 className="w-3 h-3" />
1939
+ Delete
1940
+ </button>
1941
+ </div>
1942
+ )}
1943
+ </div>
1944
+ );
1945
+ };
1946
+
1709
1947
  return (
1710
- <div className="fixed inset-0 z-40 bg-black/40">
1948
+ <div className="fixed inset-0 z-40 pointer-events-none">
1711
1949
  <div
1712
- className="absolute flex flex-col rounded-2xl border border-slate-800 bg-slate-950 shadow-2xl overflow-hidden"
1950
+ className="absolute pointer-events-auto flex flex-col rounded-2xl border border-slate-800 bg-slate-950 shadow-2xl overflow-hidden"
1713
1951
  style={containerStyle}
1714
1952
  >
1715
1953
  {/* Resize handles */}
@@ -2021,155 +2259,7 @@ interface ImportMeta {
2021
2259
  in workspace root • hold Option/Alt to copy
2022
2260
  </div>
2023
2261
  )}
2024
- {grouped.map(({ folder, files }) => {
2025
- const collapsed = collapsedFolders.has(folder);
2026
- return (
2027
- <div key={folder || "root"} className="mb-1">
2028
- {folder && (
2029
- <button
2030
- onClick={() => toggleFolder(folder)}
2031
- onDragOver={(e) => handleFolderDragOver(e, folder)}
2032
- onDragLeave={() =>
2033
- setDragOverFolder((current) =>
2034
- current === folder ? null : current,
2035
- )
2036
- }
2037
- onDrop={(e) => handleFolderDrop(e, folder)}
2038
- className="flex items-center gap-1 w-full px-1 py-0.5 text-slate-400 hover:text-slate-200"
2039
- title="Drop a file here to move it. Hold Option/Alt while dropping to copy."
2040
- >
2041
- {collapsed ? (
2042
- <ChevronRight className="w-3 h-3" />
2043
- ) : (
2044
- <ChevronDown className="w-3 h-3" />
2045
- )}
2046
- <Folder className="w-3 h-3" />
2047
- <span
2048
- className={`truncate rounded px-1 ${
2049
- dragOverFolder === folder
2050
- ? "bg-amber-500/15 text-amber-200"
2051
- : ""
2052
- }`}
2053
- >
2054
- {folder}/
2055
- </span>
2056
- </button>
2057
- )}
2058
- {!collapsed &&
2059
- files.map((filePath) => (
2060
- <div
2061
- key={filePath}
2062
- data-selected={selectedFiles.has(filePath)}
2063
- draggable
2064
- onDragStart={(e) => handleFileDragStart(e, filePath)}
2065
- onDragEnd={() => {
2066
- setDraggingFile(null);
2067
- setDragOverFolder(null);
2068
- }}
2069
- className={`group relative flex items-center gap-1 pl-${folder ? 5 : 1} pr-1 py-0.5 rounded cursor-pointer ${
2070
- activeFile === filePath
2071
- ? "bg-amber-500/15 text-amber-200"
2072
- : selectedFiles.has(filePath)
2073
- ? "bg-sky-500/10 text-sky-100 hover:bg-sky-500/15"
2074
- : "text-slate-300 hover:bg-slate-800/40"
2075
- }`}
2076
- onClick={() => setActiveFile(filePath)}
2077
- style={{ paddingLeft: folder ? 20 : 6 }}
2078
- >
2079
- {(selectMode || selectedFiles.has(filePath)) && (
2080
- <input
2081
- type="checkbox"
2082
- checked={selectedFiles.has(filePath)}
2083
- onClick={(e) => e.stopPropagation()}
2084
- onChange={() => toggleFileSelection(filePath)}
2085
- className="h-3 w-3 shrink-0 accent-amber-400"
2086
- title="Select file"
2087
- />
2088
- )}
2089
- <span className="truncate flex-1">
2090
- {baseName(filePath)}
2091
- </span>
2092
- <button
2093
- onClick={(e) => {
2094
- e.stopPropagation();
2095
- setOpenFileMenu((current) =>
2096
- current === filePath ? null : filePath,
2097
- );
2098
- setBulkMenuOpen(false);
2099
- }}
2100
- className="rounded px-1 text-slate-500 opacity-70 hover:bg-slate-800/70 hover:text-amber-200 group-hover:opacity-100"
2101
- title="File actions"
2102
- >
2103
-
2104
- </button>
2105
- {openFileMenu === filePath && (
2106
- <div
2107
- onClick={(e) => e.stopPropagation()}
2108
- className="absolute right-1 top-6 z-40 w-40 overflow-hidden rounded-lg border border-slate-700 bg-slate-950 py-1 text-[11px] shadow-xl"
2109
- >
2110
- <button
2111
- onClick={() => moveFile(filePath)}
2112
- className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
2113
- >
2114
- <Pencil className="w-3 h-3 text-amber-300" />
2115
- Move / rename…
2116
- </button>
2117
- <button
2118
- onClick={() => copyFile(filePath)}
2119
- className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
2120
- >
2121
- <Copy className="w-3 h-3 text-sky-300" />
2122
- Copy to path…
2123
- </button>
2124
- <button
2125
- onClick={() => {
2126
- toggleFileSelection(filePath);
2127
- setOpenFileMenu(null);
2128
- }}
2129
- className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
2130
- >
2131
- <ListChecks className="w-3 h-3 text-amber-300" />
2132
- {selectedFiles.has(filePath)
2133
- ? "Deselect"
2134
- : "Select"}
2135
- </button>
2136
- {selectedFileList.length > 1 &&
2137
- selectedFiles.has(filePath) && (
2138
- <>
2139
- <button
2140
- onClick={() => {
2141
- moveFilesToFolder(selectedFileList);
2142
- setOpenFileMenu(null);
2143
- }}
2144
- className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
2145
- >
2146
- Move selected…
2147
- </button>
2148
- <button
2149
- onClick={() => {
2150
- copyFilesToFolder(selectedFileList);
2151
- setOpenFileMenu(null);
2152
- }}
2153
- className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
2154
- >
2155
- Copy selected…
2156
- </button>
2157
- </>
2158
- )}
2159
- <button
2160
- onClick={() => deleteFile(filePath)}
2161
- className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-red-300 hover:bg-red-500/10"
2162
- >
2163
- <Trash2 className="w-3 h-3" />
2164
- Delete
2165
- </button>
2166
- </div>
2167
- )}
2168
- </div>
2169
- ))}
2170
- </div>
2171
- );
2172
- })}
2262
+ {fileTree.map((node) => renderFileTreeNode(node, 0))}
2173
2263
  </div>
2174
2264
  </div>
2175
2265
 
@@ -10,6 +10,7 @@ import {
10
10
  import {
11
11
  AWS_GOVERNANCE_GHA_LAB,
12
12
  DEFAULT_GHA_LAB,
13
+ EMPTY_GITHUB_GHA_LAB,
13
14
  GOVERNANCE_GHA_LAB,
14
15
  parseGhaLabWorkspace,
15
16
  REACT_VITE_TYPESCRIPT_GHA_LAB,
@@ -693,6 +694,12 @@ export default function LabsPanel() {
693
694
  origin="github-actions"
694
695
  emptyText="Save a GitHub lab to reopen it here"
695
696
  newLabMenu={[
697
+ {
698
+ label: "Empty GitHub Template",
699
+ description:
700
+ "Minimal repo with blank .github/workflows/ci.yml and CODEOWNERS files",
701
+ onClick: () => openGhaLab(EMPTY_GITHUB_GHA_LAB),
702
+ },
696
703
  {
697
704
  label: "React Vite TypeScript Starter",
698
705
  description:
@@ -513,6 +513,11 @@ li {
513
513
  `,
514
514
  };
515
515
 
516
+ const EMPTY_GITHUB_LAB_FILES: Record<string, string> = {
517
+ ".github/workflows/ci.yml": "",
518
+ ".github/CODEOWNERS": "",
519
+ };
520
+
516
521
  // ─── Platform Governance Template ────────────────────────────────────────
517
522
  //
518
523
  // Mirrors a real-world "PLF-governance" mono-repo: one repo that owns
@@ -2435,6 +2440,15 @@ export const REACT_VITE_TYPESCRIPT_GHA_LAB: GithubActionsLabWorkspace = {
2435
2440
  files: REACT_VITE_TYPESCRIPT_FILES,
2436
2441
  };
2437
2442
 
2443
+ export const EMPTY_GITHUB_GHA_LAB: GithubActionsLabWorkspace = {
2444
+ version: 1,
2445
+ label: "Empty GitHub Lab Template",
2446
+ activeFile: ".github/workflows/ci.yml",
2447
+ defaultEvent: "push",
2448
+ defaultWorkflow: ".github/workflows/ci.yml",
2449
+ files: EMPTY_GITHUB_LAB_FILES,
2450
+ };
2451
+
2438
2452
  // ─── Helpers (mirror infraLab.ts API surface) ────────────────────────────
2439
2453
 
2440
2454
  function cloneGhaLabEnvironment(
@@ -1 +1 @@
1
- {"root":["./src/app.tsx","./src/api.ts","./src/browsersecuritytemplates.ts","./src/enterpriselocallab.ts","./src/ghaconcurrency.ts","./src/githubactionslab.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/browsersecuritylabmodal.tsx","./src/components/canvaslabmodal.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/deploymentlabmodal.tsx","./src/components/diagramsmodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/ghaconcurrencypanel.tsx","./src/components/ghahistorypanel.tsx","./src/components/ghajobspanel.tsx","./src/components/gitdiffpanel.tsx","./src/components/gitdiffviewermodal.tsx","./src/components/githubactionslabmodal.tsx","./src/components/infralabmodal.tsx","./src/components/labspanel.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"version":"5.9.3"}
1
+ {"root":["./src/app.tsx","./src/api.ts","./src/awsgovernanceiamlab.ts","./src/browsersecuritytemplates.ts","./src/codeowners.ts","./src/enterpriselocallab.ts","./src/ghaconcurrency.ts","./src/githubactionslab.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/browsersecuritylabmodal.tsx","./src/components/canvaslabmodal.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/deploymentlabmodal.tsx","./src/components/diagramsmodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/ghaconcurrencypanel.tsx","./src/components/ghahistorypanel.tsx","./src/components/ghajobspanel.tsx","./src/components/gitdiffpanel.tsx","./src/components/gitdiffviewermodal.tsx","./src/components/githubactionslabmodal.tsx","./src/components/infralabmodal.tsx","./src/components/labspanel.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/pullrequestpanel.tsx","./src/components/settingspanel.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"version":"5.9.3"}
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.29.0"
2
+ "version": "0.30.1"
3
3
  }