@schema2sheet/cli 1.0.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 (2) hide show
  1. package/dist/cli.mjs +344 -0
  2. package/package.json +51 -0
package/dist/cli.mjs ADDED
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { config } from "dotenv";
4
+ import { execSync } from "node:child_process";
5
+ import fs from "node:fs/promises";
6
+ import { Parser } from "@dbml/core";
7
+ import { buildRefMap, collectEnumNames, convertDbmlTableToHeaders } from "@schema2sheet/dbml";
8
+ import { generateOas } from "@schema2sheet/openapi";
9
+ import { generateGas } from "@schema2sheet/gas";
10
+ import { google } from "googleapis";
11
+
12
+ //#region src/utils/parser.ts
13
+ function parseHeaderValue(raw) {
14
+ const match = raw.match(/^(.+?)(\*)?(?::(.+))?$/);
15
+ if (!match) return null;
16
+ const [, name, required] = match;
17
+ return {
18
+ name,
19
+ required: !!required,
20
+ raw
21
+ };
22
+ }
23
+
24
+ //#endregion
25
+ //#region src/utils/helpers.ts
26
+ function escapeSheetName(name) {
27
+ if (name.includes(" ") || name.includes("'")) return `'${name.replace(/'/g, "''")}'`;
28
+ return name;
29
+ }
30
+
31
+ //#endregion
32
+ //#region src/commands/sheet-write.ts
33
+ const WRITE_SCOPES = ["https://www.googleapis.com/auth/spreadsheets"];
34
+ const DEFAULT_MANUAL_TEXT = `πŸ“‹ μ‹œνŠΈ μ‚¬μš© μ•ˆλ‚΄
35
+
36
+ β€’ 이 ν–‰(1ν–‰)은 λ§€λ‰΄μ–Όμž…λ‹ˆλ‹€. 자유둭게 μˆ˜μ •ν•˜μ„Έμš”.
37
+ β€’ 2행은 ν—€λ”μž…λ‹ˆλ‹€. 컬럼λͺ…κ³Ό νƒ€μž…μ΄ μ •μ˜λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.
38
+ β€’ 3ν–‰λΆ€ν„° 데이터λ₯Ό μž…λ ₯ν•˜μ„Έμš”.
39
+
40
+ πŸ“Œ 헀더 ν˜•μ‹: 컬럼λͺ…*:νƒ€μž…
41
+ β€’ * (λ³„ν‘œ): ν•„μˆ˜ μž…λ ₯ 컬럼 (λΉ„μ›Œλ‘˜ 수 μ—†μŒ)
42
+ β€’ νƒ€μž… μ’…λ₯˜:
43
+ - string: 일반 ν…μŠ€νŠΈ
44
+ - uuid: 고유 μ‹λ³„μž (예: 550e8400-e29b-41d4-a716-446655440000)
45
+ - email: 이메일 ν˜•μ‹ (예: user@example.com)
46
+ - uri: URL ν˜•μ‹ (예: https://example.com)
47
+ - date: λ‚ μ§œ (예: 2024-01-15)
48
+ - date-time: λ‚ μ§œ+μ‹œκ°„ (예: 2024-01-15T09:30:00Z)
49
+ - number: 숫자 (μ†Œμˆ˜μ  κ°€λŠ₯)
50
+ - integer: μ •μˆ˜
51
+ - boolean: true λ˜λŠ” false
52
+
53
+ ⚠️ μ£Όμ˜μ‚¬ν•­
54
+ β€’ id μ»¬λŸΌμ€ μˆ˜μ •ν•˜μ§€ λ§ˆμ„Έμš” (μžλ™ 생성)
55
+ β€’ νƒ€μž…μ΄λ‚˜ *(ν•„μˆ˜ μ—¬λΆ€)λ₯Ό λ³€κ²½ν•˜λ©΄ κΈ°μ‘΄ API λ™μž‘μ΄ λ‹¬λΌμ§ˆ 수 μžˆμŠ΅λ‹ˆλ‹€.
56
+ β€’ 헀더λͺ…을 λ³€κ²½ν•œ 경우, λ°˜λ“œμ‹œ OASλ₯Ό λ‹€μ‹œ μƒμ„±ν•˜μ„Έμš”: npx sheet-schema gen-oas`;
57
+ const DEFAULT_HEADER_COLOR = {
58
+ red: .71,
59
+ green: .84,
60
+ blue: .96
61
+ };
62
+ const BLACK_BORDER = {
63
+ style: "SOLID",
64
+ color: {
65
+ red: 0,
66
+ green: 0,
67
+ blue: 0
68
+ }
69
+ };
70
+ function lightenColor(color, factor = .5) {
71
+ return {
72
+ red: color.red + (1 - color.red) * factor,
73
+ green: color.green + (1 - color.green) * factor,
74
+ blue: color.blue + (1 - color.blue) * factor
75
+ };
76
+ }
77
+ async function ensureSheetHeader(options) {
78
+ const auth = new google.auth.GoogleAuth({ scopes: WRITE_SCOPES });
79
+ const sheets = google.sheets({
80
+ version: "v4",
81
+ auth
82
+ });
83
+ const existing = (await sheets.spreadsheets.get({
84
+ spreadsheetId: options.spreadsheetId,
85
+ fields: "sheets(properties(sheetId,title),bandedRanges(bandedRangeId))"
86
+ })).data.sheets?.find((sheet) => sheet.properties?.title === options.sheetName);
87
+ let sheetId = existing?.properties?.sheetId;
88
+ let created = false;
89
+ if (sheetId == null && options.createIfMissing !== false) {
90
+ sheetId = (await sheets.spreadsheets.batchUpdate({
91
+ spreadsheetId: options.spreadsheetId,
92
+ requestBody: { requests: [{ addSheet: { properties: { title: options.sheetName } } }] }
93
+ })).data.replies?.[0]?.addSheet?.properties?.sheetId;
94
+ created = true;
95
+ }
96
+ if (sheetId == null) throw new Error(`Sheet not found: ${options.sheetName}`);
97
+ const range = `${escapeSheetName(options.sheetName)}!1:2`;
98
+ const existingRows = (await sheets.spreadsheets.values.get({
99
+ spreadsheetId: options.spreadsheetId,
100
+ range
101
+ })).data.values ?? [];
102
+ const hasValues = existingRows.some((row) => row?.some((value) => String(value ?? "").trim()));
103
+ if (hasValues && !options.force) throw new Error(`Sheet "${options.sheetName}" already has data in rows 1-2. Use --force to overwrite.`);
104
+ if (hasValues && options.force) {
105
+ await sheets.spreadsheets.values.clear({
106
+ spreadsheetId: options.spreadsheetId,
107
+ range
108
+ });
109
+ const bandings = existing?.bandedRanges ?? [];
110
+ if (bandings.length > 0) await sheets.spreadsheets.batchUpdate({
111
+ spreadsheetId: options.spreadsheetId,
112
+ requestBody: { requests: bandings.map((b) => ({ deleteBanding: { bandedRangeId: b.bandedRangeId } })) }
113
+ });
114
+ }
115
+ const manualText = options.manualText ?? DEFAULT_MANUAL_TEXT;
116
+ await sheets.spreadsheets.values.update({
117
+ spreadsheetId: options.spreadsheetId,
118
+ range: `${escapeSheetName(options.sheetName)}!A1`,
119
+ valueInputOption: "RAW",
120
+ requestBody: { values: [[manualText], options.headers] }
121
+ });
122
+ const headerColor = options.headerColor ?? DEFAULT_HEADER_COLOR;
123
+ const dataRowColor = lightenColor(headerColor, .6);
124
+ await sheets.spreadsheets.batchUpdate({
125
+ spreadsheetId: options.spreadsheetId,
126
+ requestBody: { requests: [
127
+ { mergeCells: {
128
+ range: {
129
+ sheetId,
130
+ startRowIndex: 0,
131
+ endRowIndex: 1,
132
+ startColumnIndex: 0,
133
+ endColumnIndex: options.headers.length
134
+ },
135
+ mergeType: "MERGE_ALL"
136
+ } },
137
+ { updateDimensionProperties: {
138
+ range: {
139
+ sheetId,
140
+ dimension: "ROWS",
141
+ startIndex: 0,
142
+ endIndex: 1
143
+ },
144
+ properties: { pixelSize: 200 },
145
+ fields: "pixelSize"
146
+ } },
147
+ { repeatCell: {
148
+ range: {
149
+ sheetId,
150
+ startRowIndex: 0,
151
+ endRowIndex: 1,
152
+ startColumnIndex: 0,
153
+ endColumnIndex: options.headers.length
154
+ },
155
+ cell: { userEnteredFormat: {
156
+ wrapStrategy: "WRAP",
157
+ verticalAlignment: "TOP",
158
+ textFormat: { fontSize: 10 }
159
+ } },
160
+ fields: "userEnteredFormat(wrapStrategy,verticalAlignment,textFormat)"
161
+ } },
162
+ { repeatCell: {
163
+ range: {
164
+ sheetId,
165
+ startRowIndex: 1,
166
+ endRowIndex: 2,
167
+ startColumnIndex: 0,
168
+ endColumnIndex: options.headers.length
169
+ },
170
+ cell: { userEnteredFormat: {
171
+ backgroundColor: headerColor,
172
+ textFormat: { bold: true },
173
+ horizontalAlignment: "CENTER",
174
+ verticalAlignment: "MIDDLE",
175
+ borders: {
176
+ top: BLACK_BORDER,
177
+ bottom: BLACK_BORDER,
178
+ left: BLACK_BORDER,
179
+ right: BLACK_BORDER
180
+ }
181
+ } },
182
+ fields: "userEnteredFormat(backgroundColor,textFormat,horizontalAlignment,verticalAlignment,borders)"
183
+ } },
184
+ { addBanding: { bandedRange: {
185
+ range: {
186
+ sheetId,
187
+ startRowIndex: 2,
188
+ endRowIndex: 1e3,
189
+ startColumnIndex: 0,
190
+ endColumnIndex: options.headers.length
191
+ },
192
+ rowProperties: {
193
+ firstBandColor: {
194
+ red: 1,
195
+ green: 1,
196
+ blue: 1
197
+ },
198
+ secondBandColor: dataRowColor
199
+ }
200
+ } } },
201
+ { updateBorders: {
202
+ range: {
203
+ sheetId,
204
+ startRowIndex: 1,
205
+ endRowIndex: 1e3,
206
+ startColumnIndex: 0,
207
+ endColumnIndex: options.headers.length
208
+ },
209
+ top: BLACK_BORDER,
210
+ bottom: BLACK_BORDER,
211
+ left: BLACK_BORDER,
212
+ right: BLACK_BORDER,
213
+ innerHorizontal: BLACK_BORDER,
214
+ innerVertical: BLACK_BORDER
215
+ } },
216
+ { updateSheetProperties: {
217
+ properties: {
218
+ sheetId,
219
+ gridProperties: { frozenRowCount: 2 }
220
+ },
221
+ fields: "gridProperties.frozenRowCount"
222
+ } },
223
+ { autoResizeDimensions: { dimensions: {
224
+ sheetId,
225
+ dimension: "COLUMNS",
226
+ startIndex: 0,
227
+ endIndex: options.headers.length
228
+ } } }
229
+ ] }
230
+ });
231
+ return {
232
+ sheetId,
233
+ created,
234
+ updated: true,
235
+ previousHeader: hasValues ? existingRows[1]?.map((value) => String(value ?? "")) : void 0
236
+ };
237
+ }
238
+
239
+ //#endregion
240
+ //#region src/commands/apply.ts
241
+ async function applyDbml(options) {
242
+ const dbmlContent = await fs.readFile(options.filePath, "utf8");
243
+ const exported = new Parser().parse(dbmlContent, "dbml").export();
244
+ const refMap = buildRefMap(exported.schemas);
245
+ const enumNames = collectEnumNames(exported.schemas);
246
+ const tables = exported.schemas.flatMap((s) => s.tables);
247
+ if (tables.length === 0) {
248
+ console.error("No tables found in DBML file.");
249
+ process.exit(1);
250
+ }
251
+ for (const table of tables) {
252
+ const headers = convertDbmlTableToHeaders(table, refMap, enumNames);
253
+ if (!headers.some((h) => h.startsWith("id*"))) {
254
+ console.warn(`Table "${table.name}": no id* column found. Adding id*:uuid.`);
255
+ headers.unshift("id*:uuid");
256
+ }
257
+ for (const header of headers) if (!parseHeaderValue(header)) throw new Error(`Generated invalid header for table "${table.name}": ${header}`);
258
+ if (options.dryRun) {
259
+ console.log(`[dry-run] ${table.name}: ${headers.join(" | ")}`);
260
+ continue;
261
+ }
262
+ const result = await ensureSheetHeader({
263
+ spreadsheetId: options.spreadsheetId,
264
+ sheetName: table.name,
265
+ headers,
266
+ force: options.force,
267
+ createIfMissing: true
268
+ });
269
+ const action = result.created ? "Created" : "Updated";
270
+ console.log(`${action} sheet: ${table.name} (sheetId=${result.sheetId})`);
271
+ }
272
+ if (options.dryRun) {
273
+ console.log("\n[dry-run] No sheets were modified.");
274
+ return;
275
+ }
276
+ console.log("\nGenerating OpenAPI spec...");
277
+ await generateOas({
278
+ spreadsheetId: options.spreadsheetId,
279
+ title: "Spreadsheet API",
280
+ version: "0.1.0",
281
+ outputPath: "openapi.json",
282
+ schemaPath: "schema.json",
283
+ hashPath: "openapi.hash",
284
+ pathPrefix: "/api",
285
+ excludePrefixes: ["_", "#"],
286
+ includeHidden: false,
287
+ scanRows: 10,
288
+ headerRow: 2,
289
+ metadataKey: null,
290
+ enumLimit: 200,
291
+ debug: false
292
+ });
293
+ console.log("Generated: openapi.json, schema.json");
294
+ console.log("\nGenerating Google Apps Script...");
295
+ await generateGas({
296
+ schemaPath: "schema.json",
297
+ spreadsheetId: options.spreadsheetId,
298
+ outputPath: "api.gs"
299
+ });
300
+ if (options.deploy) {
301
+ console.log("\nDeploying to Google Apps Script...");
302
+ try {
303
+ execSync("clasp push", { stdio: "inherit" });
304
+ execSync("clasp deploy", { stdio: "inherit" });
305
+ console.log("Deployed successfully.");
306
+ } catch {
307
+ console.error("clasp deploy failed. Make sure clasp is installed and configured.");
308
+ console.error(" npm install -g @google/clasp");
309
+ console.error(" clasp login");
310
+ console.error(" clasp create --type webapp --rootDir .");
311
+ process.exit(1);
312
+ }
313
+ }
314
+ console.log(`\nDone: ${tables.length} table(s) processed.`);
315
+ }
316
+
317
+ //#endregion
318
+ //#region src/cli.ts
319
+ config();
320
+ const program = new Command();
321
+ program.name("sheet-schema").description("Apply DBML schema to Google Sheets and generate OpenAPI + GAS").version("1.0.0");
322
+ program.command("apply <file>").description("Apply a DBML schema: create sheets, generate OAS and GAS").option("-s, --spreadsheet-id <id>", "Spreadsheet ID (or set SPREADSHEET_ID env)").option("--force", "Overwrite existing header rows", false).option("--dry-run", "Print planned headers without writing to sheets", false).option("--deploy", "Deploy GAS to Apps Script via clasp", false).action(async (file, options) => {
323
+ const spreadsheetId = options.spreadsheetId || process.env.SPREADSHEET_ID;
324
+ if (!spreadsheetId) {
325
+ console.error("Missing spreadsheet id. Use --spreadsheet-id or SPREADSHEET_ID env.");
326
+ process.exit(1);
327
+ }
328
+ try {
329
+ await applyDbml({
330
+ filePath: file,
331
+ spreadsheetId,
332
+ force: Boolean(options.force),
333
+ dryRun: Boolean(options.dryRun),
334
+ deploy: Boolean(options.deploy)
335
+ });
336
+ } catch (error) {
337
+ console.error(error?.stack || error?.message || error);
338
+ process.exit(1);
339
+ }
340
+ });
341
+ await program.parseAsync(process.argv);
342
+
343
+ //#endregion
344
+ export { };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@schema2sheet/cli",
3
+ "version": "1.0.0",
4
+ "description": "CLI tool for schema2sheet",
5
+ "type": "module",
6
+ "main": "dist/cli.js",
7
+ "bin": {
8
+ "schema2sheet": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "keywords": [
14
+ "cli",
15
+ "schema2sheet",
16
+ "google-sheets"
17
+ ],
18
+ "license": "MIT",
19
+ "dependencies": {
20
+ "commander": "^11.1.0",
21
+ "dotenv": "^16.4.5",
22
+ "googleapis": "^140.0.0",
23
+ "@schema2sheet/openapi": "1.0.0",
24
+ "@schema2sheet/core": "1.0.0",
25
+ "@schema2sheet/gas": "1.0.0",
26
+ "@schema2sheet/sheets": "1.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22.10.2",
30
+ "typescript": "^5.7.2"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/froggy1014/schema2sheet.git"
35
+ },
36
+ "homepage": "https://github.com/froggy1014/schema2sheet#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/froggy1014/schema2sheet/issues"
39
+ },
40
+ "author": "froggy1014",
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "build": "tsdown",
46
+ "dev": "tsdown --watch",
47
+ "lint": "biome check --write .",
48
+ "clean": "rm -rf dist .turbo node_modules",
49
+ "typecheck": "tsc --noEmit"
50
+ }
51
+ }