stagent 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -755,7 +755,7 @@ async function main() {
755
755
  const candidate = join3(searchDir, "node_modules", "next", "package.json");
756
756
  if (existsSync2(candidate)) {
757
757
  const hoistedRoot = searchDir;
758
- for (const name of ["src", "public", "docs"]) {
758
+ for (const name of ["src", "public", "docs", "book", "ai-native-notes"]) {
759
759
  const dest = join3(hoistedRoot, name);
760
760
  const src = join3(appDir, name);
761
761
  if (!existsSync2(dest) && existsSync2(src)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stagent",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "AI Business Operating System — run your business with AI agents. Local-first, multi-provider, governed.",
5
5
  "keywords": [
6
6
  "ai",
@@ -71,6 +71,7 @@
71
71
  "@tailwindcss/postcss": "^4",
72
72
  "@tailwindcss/typography": "^0.5",
73
73
  "@tanstack/react-table": "^8.21.3",
74
+ "@types/node": "^22",
74
75
  "better-sqlite3": "^12",
75
76
  "class-variance-authority": "^0.7.1",
76
77
  "clsx": "^2",
@@ -78,6 +79,7 @@
78
79
  "commander": "^14",
79
80
  "cron-parser": "^5.5.0",
80
81
  "drizzle-orm": "^0.45",
82
+ "exceljs": "^4.4.0",
81
83
  "image-size": "^2.0.2",
82
84
  "js-yaml": "^4.1.1",
83
85
  "jszip": "^3.10.1",
@@ -95,14 +97,13 @@
95
97
  "react-markdown": "^10.1.0",
96
98
  "remark-gfm": "^4.0.1",
97
99
  "sharp": "^0.34.5",
98
- "smol-toml": "^1.6.0",
100
+ "smol-toml": "^1.6.1",
99
101
  "sonner": "^2.0.7",
100
102
  "sugar-high": "^1.0.0",
101
103
  "tailwind-merge": "^3",
102
104
  "tailwindcss": "^4",
103
105
  "tw-animate-css": "^1",
104
106
  "typescript": "^5",
105
- "xlsx": "^0.18.5",
106
107
  "zod": "^4.3.6"
107
108
  },
108
109
  "devDependencies": {
@@ -111,7 +112,6 @@
111
112
  "@testing-library/react": "^16.3.2",
112
113
  "@types/better-sqlite3": "^7",
113
114
  "@types/js-yaml": "^4.0.9",
114
- "@types/node": "^22",
115
115
  "@types/react": "^19",
116
116
  "@types/react-dom": "^19",
117
117
  "@types/sharp": "^0.31.1",
@@ -22,6 +22,15 @@ const jetbrainsMono = JetBrains_Mono({
22
22
  export const metadata: Metadata = {
23
23
  title: "Stagent",
24
24
  description: "AI agent task management",
25
+ icons: {
26
+ icon: [
27
+ { url: "/stagent-s-64.png", sizes: "64x64", type: "image/png" },
28
+ { url: "/icon.svg", type: "image/svg+xml" },
29
+ ],
30
+ apple: [
31
+ { url: "/stagent-s-128.png", sizes: "128x128", type: "image/png" },
32
+ ],
33
+ },
25
34
  };
26
35
 
27
36
  // Inline theme bootstrap prevents a flash between the server render and local theme preference.
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readFileSync, readdirSync, statSync } from "fs";
3
+ import { join, resolve } from "path";
4
+
5
+ /**
6
+ * Safety-net test: server-side code must NOT use process.cwd() for resolving
7
+ * app-internal assets (docs, book, public, src). Under npx distribution,
8
+ * process.cwd() returns the npm cache directory, not the app root.
9
+ *
10
+ * Allowed alternatives:
11
+ * - import.meta.dirname / __dirname (resolves relative to source file)
12
+ * - getLaunchCwd() (resolves to user's working directory)
13
+ * - Static file conventions (e.g., src/app/icon.png)
14
+ *
15
+ * Excluded:
16
+ * - bin/cli.ts (CLI entrypoint — it defines the cwd context)
17
+ * - Test files (__tests__/)
18
+ * - workspace-context.ts (defines getLaunchCwd itself, fallback is intentional)
19
+ */
20
+ describe("npx safety: no process.cwd() for app-internal asset resolution", () => {
21
+ // Project root (src/lib/__tests__/ → 3 levels up)
22
+ const PROJECT_ROOT = resolve(__dirname, "..", "..", "..");
23
+
24
+ /** Recursively collect .ts/.tsx files, skipping node_modules and __tests__ */
25
+ function collectFiles(dir: string): string[] {
26
+ const results: string[] = [];
27
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
28
+ const full = join(dir, entry.name);
29
+ if (entry.isDirectory()) {
30
+ if (entry.name === "node_modules" || entry.name === "__tests__" || entry.name === ".next") continue;
31
+ results.push(...collectFiles(full));
32
+ } else if (/\.(ts|tsx)$/.test(entry.name)) {
33
+ results.push(full);
34
+ }
35
+ }
36
+ return results;
37
+ }
38
+
39
+ // Files that are intentionally allowed to use process.cwd()
40
+ const ALLOWED_FILES = [
41
+ "bin/cli.ts", // CLI entrypoint defines cwd context
42
+ "src/lib/environment/workspace-context.ts", // defines getLaunchCwd fallback
43
+ "drizzle.config.ts", // build-time config
44
+ ];
45
+
46
+ /**
47
+ * Patterns that indicate process.cwd() is used to resolve app-internal paths.
48
+ * We look for join/resolve calls that combine process.cwd() with known app dirs.
49
+ */
50
+ const DANGEROUS_PATTERNS = [
51
+ /process\.cwd\(\)\s*,\s*["'](?:public|docs|book|ai-native-notes|src)\b/,
52
+ /process\.cwd\(\)\s*,\s*["'].*?\.(?:png|ico|svg|jpg|md|json)["']/,
53
+ ];
54
+
55
+ it("server-side code does not use process.cwd() for internal asset paths", () => {
56
+ const srcFiles = collectFiles(join(PROJECT_ROOT, "src"));
57
+ const binFiles = collectFiles(join(PROJECT_ROOT, "bin"));
58
+ const allFiles = [...srcFiles, ...binFiles];
59
+
60
+ const violations: Array<{ file: string; line: number; text: string }> = [];
61
+
62
+ for (const filePath of allFiles) {
63
+ const relative = filePath.replace(PROJECT_ROOT + "/", "");
64
+
65
+ // Skip allowed files
66
+ if (ALLOWED_FILES.some((allowed) => relative.endsWith(allowed))) continue;
67
+ // Skip test files
68
+ if (relative.includes("__tests__")) continue;
69
+
70
+ const content = readFileSync(filePath, "utf-8");
71
+ const lines = content.split("\n");
72
+
73
+ for (let i = 0; i < lines.length; i++) {
74
+ const line = lines[i];
75
+ for (const pattern of DANGEROUS_PATTERNS) {
76
+ if (pattern.test(line)) {
77
+ violations.push({ file: relative, line: i + 1, text: line.trim() });
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ expect(
84
+ violations,
85
+ `Found process.cwd() used for app-internal paths (breaks under npx):\n${violations
86
+ .map((v) => ` ${v.file}:${v.line} → ${v.text}`)
87
+ .join("\n")}`
88
+ ).toEqual([]);
89
+ });
90
+
91
+ it("dynamic icon/apple-icon files do not exist (break under npx)", () => {
92
+ const appDir = join(PROJECT_ROOT, "src", "app");
93
+ const dynamicIcons = ["icon.tsx", "icon.jsx", "apple-icon.tsx", "apple-icon.jsx"];
94
+
95
+ const found = dynamicIcons.filter((name) => {
96
+ try {
97
+ statSync(join(appDir, name));
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ });
103
+
104
+ expect(
105
+ found,
106
+ `Dynamic icon files found — these break under npx because they use process.cwd(). Use explicit metadata.icons in layout.tsx instead: ${found.join(", ")}`
107
+ ).toEqual([]);
108
+ });
109
+
110
+ it("layout.tsx has explicit icons metadata (not convention-based)", () => {
111
+ const layoutPath = join(PROJECT_ROOT, "src", "app", "layout.tsx");
112
+ const content = readFileSync(layoutPath, "utf-8");
113
+
114
+ expect(
115
+ content.includes("icons:"),
116
+ "layout.tsx must have explicit icons metadata — convention-based icon.png/icon.tsx files break under npx"
117
+ ).toBe(true);
118
+ });
119
+
120
+ it("icon assets referenced in metadata exist in public/", () => {
121
+ const publicDir = join(PROJECT_ROOT, "public");
122
+ const requiredIcons = ["stagent-s-64.png", "stagent-s-128.png", "icon.svg"];
123
+
124
+ const missing = requiredIcons.filter((name) => {
125
+ try {
126
+ statSync(join(publicDir, name));
127
+ return false;
128
+ } catch {
129
+ return true;
130
+ }
131
+ });
132
+
133
+ expect(
134
+ missing,
135
+ `Missing icon assets in public/: ${missing.join(", ")}`
136
+ ).toEqual([]);
137
+ });
138
+ });
@@ -15,20 +15,15 @@ import { eq, and } from "drizzle-orm";
15
15
  * Builtins ship inside the repo at src/lib/agents/profiles/builtins/.
16
16
  * At runtime they are copied (if missing) to ~/.claude/skills/ so users
17
17
  * can customize them without touching source.
18
+ * Uses import.meta.dirname (not process.cwd()) so it works under npx.
18
19
  */
19
- const BUILTINS_DIR_PRIMARY = path.resolve(
20
+ const BUILTINS_DIR = path.resolve(
20
21
  import.meta.dirname ?? __dirname,
21
22
  "builtins"
22
23
  );
23
- const BUILTINS_DIR_FALLBACK = path.join(
24
- process.cwd(),
25
- "src/lib/agents/profiles/builtins"
26
- );
27
24
 
28
- /** Resolve builtins dir — import.meta.dirname may point to .next/ in bundled contexts */
29
25
  function getBuiltinsDir(): string {
30
- if (fs.existsSync(BUILTINS_DIR_PRIMARY)) return BUILTINS_DIR_PRIMARY;
31
- return BUILTINS_DIR_FALLBACK;
26
+ return BUILTINS_DIR;
32
27
  }
33
28
 
34
29
  const SKILLS_DIR = path.join(
@@ -31,8 +31,11 @@ export function gatherChapterContext(chapterId: string): ChapterContext {
31
31
  const sourceDocSlugs = mapping?.docs ?? [];
32
32
  const slug = chapterIdToSlug(chapterId);
33
33
 
34
+ // Resolve paths relative to source file, not cwd (npx-safe)
35
+ const appRoot = join(import.meta.dirname ?? __dirname, "..", "..", "..");
36
+
34
37
  // Read the current chapter markdown (if it exists)
35
- const chapterMdPath = join(process.cwd(), "book", "chapters", `${slug}.md`);
38
+ const chapterMdPath = join(appRoot, "book", "chapters", `${slug}.md`);
36
39
  const currentMarkdown = existsSync(chapterMdPath)
37
40
  ? readFileSync(chapterMdPath, "utf-8")
38
41
  : null;
@@ -40,7 +43,7 @@ export function gatherChapterContext(chapterId: string): ChapterContext {
40
43
  // Read related playbook docs for content
41
44
  const sourceContents: string[] = [];
42
45
  for (const docSlug of sourceDocSlugs) {
43
- const docPath = join(process.cwd(), "docs", "features", `${docSlug}.md`);
46
+ const docPath = join(appRoot, "docs", "features", `${docSlug}.md`);
44
47
  if (existsSync(docPath)) {
45
48
  const content = readFileSync(docPath, "utf-8");
46
49
  sourceContents.push(`### Feature: ${docSlug}\n${content}`);
@@ -48,7 +51,7 @@ export function gatherChapterContext(chapterId: string): ChapterContext {
48
51
  }
49
52
 
50
53
  // Read the book strategy document
51
- const strategyPath = join(process.cwd(), "ai-native-notes", "ai-native-book-strategy.md");
54
+ const strategyPath = join(appRoot, "ai-native-notes", "ai-native-book-strategy.md");
52
55
  const strategy = existsSync(strategyPath)
53
56
  ? readFileSync(strategyPath, "utf-8")
54
57
  : null;
@@ -154,7 +154,9 @@ function tryLoadMarkdownChapter(id: string): BookChapter | null {
154
154
  // eslint-disable-next-line @typescript-eslint/no-require-imports
155
155
  const { parseMarkdownChapter } = require("./markdown-parser") as { parseMarkdownChapter: (md: string, slug: string) => { sections: Array<{ id: string; title: string; content: import("./types").ContentBlock[] }> } };
156
156
 
157
- const filePath = join(process.cwd(), "book", "chapters", `${fileSlug}.md`);
157
+ // Resolve relative to source file, not cwd (npx-safe)
158
+ const appRoot = join(import.meta.dirname ?? __dirname, "..", "..", "..");
159
+ const filePath = join(appRoot, "book", "chapters", `${fileSlug}.md`);
158
160
  if (!existsSync(filePath)) return null;
159
161
 
160
162
  const content = readFileSync(filePath, "utf-8");
@@ -33,7 +33,9 @@ function getLastGenerated(chapterId: string): string | null {
33
33
  const slug = CHAPTER_SLUGS[chapterId];
34
34
  if (!slug) return null;
35
35
 
36
- const mdPath = join(process.cwd(), "book", "chapters", `${slug}.md`);
36
+ // Resolve relative to source file, not cwd (npx-safe)
37
+ const appRoot = join(import.meta.dirname ?? __dirname, "..", "..", "..");
38
+ const mdPath = join(appRoot, "book", "chapters", `${slug}.md`);
37
39
  if (!existsSync(mdPath)) return null;
38
40
 
39
41
  const content = readFileSync(mdPath, "utf-8");
@@ -162,15 +162,17 @@ async function createPptx(slides: string[]): Promise<Buffer> {
162
162
  return Buffer.from(buf);
163
163
  }
164
164
 
165
- /** Create a valid XLSX using the xlsx library */
166
- function createXlsxSync(csvContent: string): Buffer {
167
- // eslint-disable-next-line @typescript-eslint/no-require-imports
168
- const XLSX = require("xlsx");
165
+ /** Create a valid XLSX using exceljs */
166
+ async function createXlsx(csvContent: string): Promise<Buffer> {
167
+ const ExcelJS = await import("exceljs");
168
+ const workbook = new ExcelJS.Workbook();
169
+ const ws = workbook.addWorksheet("Sheet1");
169
170
  const rows = csvContent.split("\n").map((line: string) => line.split(","));
170
- const ws = XLSX.utils.aoa_to_sheet(rows);
171
- const wb = XLSX.utils.book_new();
172
- XLSX.utils.book_append_sheet(wb, ws, "Sheet1");
173
- return XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer;
171
+ for (const row of rows) {
172
+ ws.addRow(row);
173
+ }
174
+ const arrayBuffer = await workbook.xlsx.writeBuffer();
175
+ return Buffer.from(arrayBuffer);
174
176
  }
175
177
 
176
178
  /** Create a minimal valid PDF with text content */
@@ -254,9 +256,8 @@ const DOCUMENTS: DocumentDef[] = [
254
256
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
255
257
  projectIndex: 0,
256
258
  taskIndex: 0,
257
- content: () =>
258
- Promise.resolve(
259
- createXlsxSync(
259
+ content: async () =>
260
+ createXlsx(
260
261
  `Ticker,Shares,Avg Cost,Current Price,Market Value,Sector
261
262
  NVDA,45,285.50,890.25,40061.25,Technology
262
263
  AAPL,120,142.30,178.50,21420.00,Technology
@@ -273,7 +274,6 @@ LLY,15,580.00,782.50,11737.50,Healthcare
273
274
  HD,35,325.00,348.90,12211.50,Consumer
274
275
  MA,40,368.50,462.80,18512.00,Finance
275
276
  CVX,55,152.30,158.40,8712.00,Energy`
276
- )
277
277
  ),
278
278
  },
279
279
  {
@@ -398,9 +398,8 @@ Tertiary: Newsletter subscription`
398
398
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
399
399
  projectIndex: 2,
400
400
  taskIndex: 10,
401
- content: () =>
402
- Promise.resolve(
403
- createXlsxSync(
401
+ content: async () =>
402
+ createXlsx(
404
403
  `Name,Title,Company,Industry,Size,LinkedIn URL,Email,Status
405
404
  Sarah Chen,VP Engineering,Acme Corp,SaaS,500-1000,linkedin.com/in/sarachen,s.chen@acmecorp.com,Qualified
406
405
  Marcus Johnson,Director of Engineering,Acme Corp,SaaS,500-1000,linkedin.com/in/marcusjohnson,m.johnson@acmecorp.com,Qualified
@@ -414,7 +413,6 @@ Nina Patel,CTO,QuickShip,Logistics,200-500,linkedin.com/in/ninapatel,n.patel@qui
414
413
  Alex Turner,Director of Engineering,BrightPath,EdTech,100-200,linkedin.com/in/alexturner,a.turner@brightpath.edu,Qualified
415
414
  Sophie Reed,VP Product,GreenGrid,CleanTech,50-200,linkedin.com/in/sophiereed,s.reed@greengrid.io,Researching
416
415
  Chris Wong,VP Engineering,NexaPay,FinTech,200-500,linkedin.com/in/chriswong,c.wong@nexapay.com,Qualified`
417
- )
418
416
  ),
419
417
  },
420
418
  {
@@ -514,9 +512,8 @@ Total: $2,052 (Pre-approved #EXP-2025-0342)`
514
512
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
515
513
  projectIndex: 3,
516
514
  taskIndex: 19,
517
- content: () =>
518
- Promise.resolve(
519
- createXlsxSync(
515
+ content: async () =>
516
+ createXlsx(
520
517
  `Date,Category,Vendor,Amount,Description,Receipt
521
518
  2025-03-15,Airfare,United Airlines,342.00,SFO to JFK Economy Plus UA 456,receipt-001.pdf
522
519
  2025-03-15,Ground Transport,Uber,62.50,JFK to Manhattan Club hotel,receipt-002.pdf
@@ -536,7 +533,6 @@ Total: $2,052 (Pre-approved #EXP-2025-0342)`
536
533
  2025-03-15,Meals,Starbucks JFK,8.40,Coffee at terminal,receipt-016.pdf
537
534
  2025-03-16,Miscellaneous,CVS Pharmacy,12.80,Phone charger,receipt-017.pdf
538
535
  2025-03-17,Miscellaneous,Hotel Concierge,15.00,Luggage storage tip,receipt-018.pdf`
539
- )
540
536
  ),
541
537
  },
542
538
 
@@ -590,9 +586,8 @@ STATUS: 7 of 8 documents collected (87.5%)`
590
586
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
591
587
  projectIndex: 4,
592
588
  taskIndex: 21,
593
- content: () =>
594
- Promise.resolve(
595
- createXlsxSync(
589
+ content: async () =>
590
+ createXlsx(
596
591
  `Date,Category,Description,Amount,Deductible,Notes
597
592
  2025-01-05,Home Office,Internet service (pro-rata),45.00,Yes,8.3% of total
598
593
  2025-01-05,Home Office,Electricity (pro-rata),32.00,Yes,8.3% of total
@@ -619,7 +614,6 @@ STATUS: 7 of 8 documents collected (87.5%)`
619
614
  2025-04-01,Health,HSA contribution,500.00,Yes,Monthly
620
615
  2025-04-05,Home Office,Internet (pro-rata),45.00,Yes,Monthly
621
616
  2025-04-05,Home Office,Electricity (pro-rata),38.00,Yes,Spring cycle`
622
- )
623
617
  ),
624
618
  },
625
619
 
@@ -2,9 +2,11 @@ import { readFileSync, readdirSync, existsSync } from "fs";
2
2
  import { join, basename } from "path";
3
3
  import type { DocManifest, ParsedDoc } from "./types";
4
4
 
5
- /** Resolve the docs directory relative to project root */
5
+ /** Resolve the docs directory relative to this source file (npx-safe) */
6
6
  function docsDir(): string {
7
- return join(process.cwd(), "docs");
7
+ const dir = import.meta.dirname ?? __dirname;
8
+ // src/lib/docs/ → project root → docs/
9
+ return join(dir, "..", "..", "..", "docs");
8
10
  }
9
11
 
10
12
  /** Read and parse docs/manifest.json */
@@ -3,16 +3,20 @@ import type { ProcessorResult } from "../registry";
3
3
 
4
4
  /** Parse XLSX/CSV to a text table representation */
5
5
  export async function processSpreadsheet(filePath: string): Promise<ProcessorResult> {
6
- const XLSX = await import("xlsx");
6
+ const ExcelJS = await import("exceljs");
7
+ const workbook = new ExcelJS.Workbook();
7
8
  const buffer = await readFile(filePath);
8
- const workbook = XLSX.read(buffer, { type: "buffer" });
9
+ await workbook.xlsx.load(buffer as unknown as Buffer);
9
10
 
10
11
  const sheets: string[] = [];
11
- for (const sheetName of workbook.SheetNames) {
12
- const sheet = workbook.Sheets[sheetName];
13
- const csv = XLSX.utils.sheet_to_csv(sheet);
14
- sheets.push(`--- Sheet: ${sheetName} ---\n${csv}`);
15
- }
12
+ workbook.eachSheet((worksheet) => {
13
+ const rows: string[] = [];
14
+ worksheet.eachRow((row) => {
15
+ const values = (row.values as (string | number | null | undefined)[]).slice(1); // ExcelJS is 1-indexed
16
+ rows.push(values.map((v) => (v ?? "").toString()).join(","));
17
+ });
18
+ sheets.push(`--- Sheet: ${worksheet.name} ---\n${rows.join("\n")}`);
19
+ });
16
20
 
17
21
  return { extractedText: sheets.join("\n\n") };
18
22
  }
@@ -1,31 +0,0 @@
1
- import { ImageResponse } from "next/og";
2
- import { readFileSync } from "fs";
3
- import { join } from "path";
4
-
5
- export const size = { width: 180, height: 180 };
6
- export const contentType = "image/png";
7
-
8
- export default function AppleIcon() {
9
- const logoData = readFileSync(join(process.cwd(), "public/stagent-s-128.png"));
10
- const logoSrc = `data:image/png;base64,${logoData.toString("base64")}`;
11
-
12
- return new ImageResponse(
13
- (
14
- <div
15
- style={{
16
- width: "100%",
17
- height: "100%",
18
- display: "flex",
19
- alignItems: "center",
20
- justifyContent: "center",
21
- background: "#0f172a",
22
- borderRadius: "36px",
23
- }}
24
- >
25
- {/* eslint-disable-next-line @next/next/no-img-element */}
26
- <img src={logoSrc} width="130" height="130" alt="" />
27
- </div>
28
- ),
29
- { ...size }
30
- );
31
- }
package/src/app/icon.tsx DELETED
@@ -1,30 +0,0 @@
1
- import { ImageResponse } from "next/og";
2
- import { readFileSync } from "fs";
3
- import { join } from "path";
4
-
5
- export const size = { width: 32, height: 32 };
6
- export const contentType = "image/png";
7
-
8
- export default function Icon() {
9
- const logoData = readFileSync(join(process.cwd(), "public/stagent-s-64.png"));
10
- const logoSrc = `data:image/png;base64,${logoData.toString("base64")}`;
11
-
12
- return new ImageResponse(
13
- (
14
- <div
15
- style={{
16
- width: "100%",
17
- height: "100%",
18
- display: "flex",
19
- alignItems: "center",
20
- justifyContent: "center",
21
- background: "transparent",
22
- }}
23
- >
24
- {/* eslint-disable-next-line @next/next/no-img-element */}
25
- <img src={logoSrc} width="30" height="30" alt="" />
26
- </div>
27
- ),
28
- { ...size }
29
- );
30
- }