nsa-sheets-db-builder 4.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/bin/sheets-deployer.mjs +169 -0
  4. package/libs/alasql.js +15577 -0
  5. package/libs/common/gas_response_helper.ts +147 -0
  6. package/libs/common/gaserror.ts +101 -0
  7. package/libs/common/gaslogger.ts +172 -0
  8. package/libs/db_ddl.ts +316 -0
  9. package/libs/libraries.json +56 -0
  10. package/libs/spreadsheets_db.ts +4406 -0
  11. package/libs/triggers.ts +113 -0
  12. package/package.json +73 -0
  13. package/scripts/build.mjs +513 -0
  14. package/scripts/clean.mjs +31 -0
  15. package/scripts/create.mjs +94 -0
  16. package/scripts/ddl-handler.mjs +232 -0
  17. package/scripts/describe.mjs +38 -0
  18. package/scripts/drop.mjs +39 -0
  19. package/scripts/init.mjs +465 -0
  20. package/scripts/lib/utils.mjs +1019 -0
  21. package/scripts/login.mjs +102 -0
  22. package/scripts/provision.mjs +35 -0
  23. package/scripts/refresh-cache.mjs +34 -0
  24. package/scripts/set-key.mjs +48 -0
  25. package/scripts/setup-trigger.mjs +95 -0
  26. package/scripts/setup.mjs +677 -0
  27. package/scripts/show.mjs +37 -0
  28. package/scripts/sync.mjs +35 -0
  29. package/scripts/whoami.mjs +36 -0
  30. package/src/api/ddl-handler-entry.ts +136 -0
  31. package/src/api/ddl.ts +321 -0
  32. package/src/templates/.clasp.json.ejs +1 -0
  33. package/src/templates/appsscript.json.ejs +16 -0
  34. package/src/templates/config.ts.ejs +14 -0
  35. package/src/templates/ddl-handler-config.ts.ejs +3 -0
  36. package/src/templates/ddl-handler-main.ts.ejs +56 -0
  37. package/src/templates/main.ts.ejs +288 -0
  38. package/src/templates/rbac.ts.ejs +148 -0
  39. package/src/templates/views.ts.ejs +92 -0
  40. package/templates/blank.json +33 -0
  41. package/templates/blog-cms.json +507 -0
  42. package/templates/crm.json +360 -0
  43. package/templates/e-commerce.json +424 -0
  44. package/templates/inventory.json +307 -0
@@ -0,0 +1,677 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Automated full deployment for nsa-sheets-db-builder.
5
+ *
6
+ * Prerequisite: `init` + `login` (both require interactive input — can't automate)
7
+ *
8
+ * Steps:
9
+ * 1. Pre-flight — validate config, check auth, load features
10
+ * 2. DDL Handler (per-account, shared) — create/skip, build, push, deploy
11
+ * 3. Create DB GAS projects — `clasp create` for each placeholder instance
12
+ * 4. Build DB instances
13
+ * 5. Push DB instances
14
+ * 6. Deploy DB instances → saves deploymentId to project.json
15
+ * 7. Create Drive resources (HTTP → DDL handler web app)
16
+ * 8. Re-build + re-push + re-deploy DB instances (with real IDs)
17
+ * 9. Provision tables (HTTP → DDL handler web app)
18
+ * 10. Rotate API keys (HTTP → DB script web app)
19
+ * 11. Summary
20
+ *
21
+ * Usage:
22
+ * node scripts/setup.mjs --db <name> [--env <env>] [--force]
23
+ */
24
+
25
+ import fs from 'fs';
26
+ import os from 'os';
27
+ import path from 'path';
28
+ import crypto from 'crypto';
29
+ import https from 'https';
30
+ import { execSync } from 'child_process';
31
+ import {
32
+ parseArgs, requireDb, loadDbConfig, getEnvConfig,
33
+ getAllInstances, getDistDirForInstance,
34
+ loadTables, saveTables, saveProjectConfig,
35
+ loadRbacConfig, loadViewsConfig, loadCustomMethodsConfig,
36
+ getFeatures, validateRbacRequirements,
37
+ ensureAuthenticated, isPlaceholderScriptId,
38
+ getDataSpreadsheetIds,
39
+ loadRootConfig, saveRootConfig, resolveAccount,
40
+ getDdlHandlerDistDir
41
+ } from './lib/utils.mjs';
42
+ import { buildInstance, buildDdlHandler, getDeploymentDescription } from './build.mjs';
43
+
44
+ // ────────────────────────────────────────
45
+ // Helpers
46
+ // ────────────────────────────────────────
47
+
48
+ function parseDeploymentId(output) {
49
+ // clasp deploy outputs: "Deployed AKfyc... @1"
50
+ // clasp deployments lists: "- AKfyc... @1"
51
+ const match = output.match(/(?:^Deployed |^- )(\S+) @/m);
52
+ return match ? match[1] : null;
53
+ }
54
+
55
+ /** Build the --user flag string (empty when using default credentials). */
56
+ function userFlag(profile) {
57
+ return profile ? ` --user ${profile}` : '';
58
+ }
59
+
60
+ function step(n, label) {
61
+ console.log(`\n${'─'.repeat(50)}`);
62
+ console.log(` Step ${n}: ${label}`);
63
+ console.log('─'.repeat(50));
64
+ }
65
+
66
+ /** Generate a random admin key for DDL operations. */
67
+ function generateAdminKey() {
68
+ return crypto.randomBytes(24).toString('hex');
69
+ }
70
+
71
+ /**
72
+ * Call a DDL function on a deployed web app via HTTP POST.
73
+ * Uses the ddlAdminKey for authentication.
74
+ */
75
+ function callWebApp(deploymentId, ddlAction, params, ddlAdminKey) {
76
+ const url = `https://script.google.com/macros/s/${deploymentId}/exec`;
77
+ const body = JSON.stringify({ ddlAction, ddlKey: ddlAdminKey, params });
78
+
79
+ console.log(` → POST ${ddlAction} to web app`);
80
+
81
+ return new Promise((resolve, reject) => {
82
+ function doRequest(requestUrl, redirectCount) {
83
+ if (redirectCount > 5) {
84
+ reject(new Error('Too many redirects'));
85
+ return;
86
+ }
87
+
88
+ const parsed = new URL(requestUrl);
89
+ const options = {
90
+ hostname: parsed.hostname,
91
+ path: parsed.pathname + parsed.search,
92
+ method: 'POST',
93
+ headers: {
94
+ 'Content-Type': 'application/json',
95
+ 'Content-Length': Buffer.byteLength(body)
96
+ }
97
+ };
98
+
99
+ const req = https.request(options, (res) => {
100
+ // Follow redirects (GAS web apps redirect 302)
101
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
102
+ doRequest(res.headers.location, redirectCount + 1);
103
+ res.resume();
104
+ return;
105
+ }
106
+
107
+ let data = '';
108
+ res.on('data', chunk => data += chunk);
109
+ res.on('end', () => {
110
+ if (res.statusCode !== 200) {
111
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
112
+ return;
113
+ }
114
+ try {
115
+ resolve(JSON.parse(data));
116
+ } catch {
117
+ reject(new Error(`Non-JSON response: ${data.substring(0, 200)}`));
118
+ }
119
+ });
120
+ });
121
+
122
+ req.on('error', reject);
123
+ req.setTimeout(120000, () => {
124
+ req.destroy(new Error('Request timed out (120s)'));
125
+ });
126
+ req.write(body);
127
+ req.end();
128
+ }
129
+
130
+ doRequest(url, 0);
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Deploy (or update) a single instance. Saves deploymentId to config.
136
+ * Returns the deploymentId.
137
+ */
138
+ function deployInstance(inst, dbName, env, envConfig, userProfile, config) {
139
+ const distDir = getDistDirForInstance(dbName, inst.type);
140
+ const desc = getDeploymentDescription(dbName, inst.type, env);
141
+
142
+ if (inst.deploymentId) {
143
+ console.log(`\n Updating deployment for ${inst.type}: ${inst.deploymentId}`);
144
+ execSync(
145
+ `npx --prefer-offline @google/clasp update-deployment ${inst.deploymentId} --description '${desc}'${userFlag(userProfile)}`,
146
+ { cwd: distDir, stdio: 'inherit' }
147
+ );
148
+ return inst.deploymentId;
149
+ }
150
+
151
+ console.log(`\n Creating new deployment for ${inst.type}: "${desc}"`);
152
+ const output = execSync(
153
+ `npx --prefer-offline @google/clasp deploy --description '${desc}'${userFlag(userProfile)}`,
154
+ { cwd: distDir, encoding: 'utf8' }
155
+ );
156
+
157
+ const deploymentId = parseDeploymentId(output);
158
+ if (deploymentId) {
159
+ console.log(` Deployment ID: ${deploymentId}`);
160
+ envConfig.instances[inst.scriptId].deploymentId = deploymentId;
161
+ saveProjectConfig(dbName, config);
162
+ } else {
163
+ console.error(` ERROR: could not parse deployment ID from clasp output:`);
164
+ console.error(output);
165
+ process.exit(1);
166
+ }
167
+ return deploymentId;
168
+ }
169
+
170
+ // ────────────────────────────────────────
171
+ // Main
172
+ // ────────────────────────────────────────
173
+
174
+ async function setup() {
175
+ // 0. Parse args
176
+ const args = parseArgs();
177
+ const dbName = requireDb(args, 'setup.mjs');
178
+
179
+ let config = loadDbConfig(dbName);
180
+ const { env, envConfig } = getEnvConfig(config, args.env);
181
+ const features = getFeatures(config);
182
+
183
+ console.log(`\nnsa-sheets-db-builder setup — ${dbName} (${env})`);
184
+ console.log('='.repeat(50));
185
+
186
+ // ── Step 1: Pre-flight ──────────────────
187
+
188
+ step(1, 'Pre-flight checks');
189
+
190
+ let instances = getAllInstances(envConfig);
191
+ if (instances.length === 0) {
192
+ console.error('No instances configured for this environment.');
193
+ console.error(`Check dbs/${dbName}/project.json → environments.${env}.instances`);
194
+ process.exit(1);
195
+ }
196
+ console.log(` Instances: ${instances.map(i => i.type).join(', ')}`);
197
+
198
+ const userProfile = await ensureAuthenticated(dbName, args, envConfig, env);
199
+
200
+ // Generate per-instance DDL admin key if not set
201
+ if (!envConfig.ddlAdminKey) {
202
+ envConfig.ddlAdminKey = generateAdminKey();
203
+ saveProjectConfig(dbName, config);
204
+ console.log(' Generated DDL admin key (per-instance).');
205
+ } else {
206
+ console.log(' DDL admin key (per-instance): already set.');
207
+ }
208
+
209
+ // Load feature configs
210
+ let rbacConfig = null;
211
+ let viewsConfig = null;
212
+ const customMethodsConfig = loadCustomMethodsConfig(dbName);
213
+
214
+ if (features.rbac.enabled) {
215
+ rbacConfig = loadRbacConfig(dbName);
216
+ if (!rbacConfig) {
217
+ console.error('RBAC enabled in project.json but rbac.json not found.');
218
+ process.exit(1);
219
+ }
220
+ const tables = loadTables(dbName);
221
+ const errors = validateRbacRequirements(tables, rbacConfig);
222
+ if (errors.length > 0) {
223
+ console.error('RBAC validation failed:');
224
+ for (const err of errors) console.error(` - ${err}`);
225
+ process.exit(1);
226
+ }
227
+ console.log(` RBAC: enabled (${features.authMode} auth)`);
228
+ }
229
+
230
+ if (features.views.enabled) {
231
+ viewsConfig = loadViewsConfig(dbName);
232
+ if (!viewsConfig) {
233
+ console.error('Views enabled in project.json but views.json not found.');
234
+ process.exit(1);
235
+ }
236
+ console.log(` Views: enabled (${Object.keys(viewsConfig).length} view(s))`);
237
+ }
238
+
239
+ console.log(' Pre-flight passed.');
240
+
241
+ // ── Step 2: DDL Handler (per-account, shared) ──
242
+
243
+ step(2, 'DDL Handler (per-account)');
244
+
245
+ const account = resolveAccount(envConfig, env);
246
+ if (!account) {
247
+ console.error(' ERROR: account not set.');
248
+ console.error(' Set "account" in .nsaproject.json → environments.' + env);
249
+ console.error(' Or run login first to auto-detect.');
250
+ process.exit(1);
251
+ }
252
+
253
+ // Resolve DDL handler from root config (.nsaproject.json)
254
+ let rootConfig = loadRootConfig();
255
+ let rootEnv = rootConfig.environments?.[env] || {};
256
+ let ddlHandler = rootEnv.ddlHandler || {};
257
+
258
+ if (ddlHandler.scriptId && ddlHandler.deploymentId) {
259
+ console.log(` DDL handler already deployed for ${account}`);
260
+ console.log(` scriptId: ${ddlHandler.scriptId}`);
261
+ console.log(` deploymentId: ${ddlHandler.deploymentId}`);
262
+ console.log(' Skipping DDL handler creation.');
263
+ } else {
264
+ console.log(` No DDL handler for ${account} — creating...`);
265
+
266
+ // a. Generate ddlAdminKey for the handler
267
+ const handlerAdminKey = ddlHandler.ddlAdminKey || generateAdminKey();
268
+
269
+ // b. clasp create → get scriptId
270
+ let handlerScriptId = ddlHandler.scriptId || '';
271
+ if (!handlerScriptId) {
272
+ const title = `ddl-handler-${account.split('@')[0]}`;
273
+ console.log(`\n Creating GAS project: ${title}`);
274
+
275
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nsa-sheets-db-builder-ddl-'));
276
+ try {
277
+ execSync(
278
+ `npx --prefer-offline @google/clasp create --type webapp --title "${title}" --rootDir .${userFlag(userProfile)}`,
279
+ { cwd: tmpDir, stdio: 'pipe' }
280
+ );
281
+
282
+ const claspJson = JSON.parse(fs.readFileSync(path.join(tmpDir, '.clasp.json'), 'utf8'));
283
+ handlerScriptId = claspJson.scriptId;
284
+
285
+ if (!handlerScriptId) {
286
+ console.error(' ERROR: clasp create succeeded but no scriptId found.');
287
+ process.exit(1);
288
+ }
289
+
290
+ console.log(` scriptId: ${handlerScriptId}`);
291
+ } catch (err) {
292
+ const stderr = err.stderr?.toString() || '';
293
+ const stdout = err.stdout?.toString() || '';
294
+ console.error('\n clasp create failed for DDL handler:');
295
+ if (stderr) console.error(stderr);
296
+ if (stdout) console.error(stdout);
297
+ process.exit(1);
298
+ } finally {
299
+ fs.rmSync(tmpDir, { recursive: true, force: true });
300
+ }
301
+ }
302
+
303
+ // c. Build DDL handler
304
+ const loggingVerbosity = config.settings?.loggingVerbosity ?? 2;
305
+ const projectId = rootEnv.projectId || '';
306
+ buildDdlHandler(handlerAdminKey, loggingVerbosity, handlerScriptId, projectId);
307
+
308
+ // d. clasp push
309
+ const ddlDistDir = getDdlHandlerDistDir();
310
+ console.log('\n Pushing DDL handler...');
311
+ try {
312
+ execSync(`npx --prefer-offline @google/clasp push --force${userFlag(userProfile)}`, {
313
+ cwd: ddlDistDir,
314
+ stdio: 'inherit'
315
+ });
316
+ } catch {
317
+ console.error(' clasp push failed for DDL handler.');
318
+ process.exit(1);
319
+ }
320
+
321
+ // e. clasp deploy → get deploymentId
322
+ let handlerDeploymentId = ddlHandler.deploymentId || '';
323
+ if (handlerDeploymentId) {
324
+ console.log(`\n Updating DDL handler deployment: ${handlerDeploymentId}`);
325
+ execSync(
326
+ `npx --prefer-offline @google/clasp update-deployment ${handlerDeploymentId} --description 'DDL Handler'${userFlag(userProfile)}`,
327
+ { cwd: ddlDistDir, stdio: 'inherit' }
328
+ );
329
+ } else {
330
+ console.log('\n Creating DDL handler deployment...');
331
+ const output = execSync(
332
+ `npx --prefer-offline @google/clasp deploy --description 'DDL Handler'${userFlag(userProfile)}`,
333
+ { cwd: ddlDistDir, encoding: 'utf8' }
334
+ );
335
+ handlerDeploymentId = parseDeploymentId(output);
336
+ if (!handlerDeploymentId) {
337
+ console.error(' ERROR: could not parse deployment ID from clasp output:');
338
+ console.error(output);
339
+ process.exit(1);
340
+ }
341
+ console.log(` deploymentId: ${handlerDeploymentId}`);
342
+ }
343
+
344
+ // f. Save to .nsaproject.json
345
+ if (!rootConfig.environments) rootConfig.environments = {};
346
+ if (!rootConfig.environments[env]) rootConfig.environments[env] = {};
347
+ rootConfig.environments[env].ddlHandler = {
348
+ scriptId: handlerScriptId,
349
+ deploymentId: handlerDeploymentId,
350
+ ddlAdminKey: handlerAdminKey
351
+ };
352
+ saveRootConfig(rootConfig);
353
+ ddlHandler = rootConfig.environments[env].ddlHandler;
354
+
355
+ console.log(`\n DDL handler saved to .nsaproject.json`);
356
+
357
+ // g. Print authorization reminder
358
+ console.log(`\n IMPORTANT: Authorize the DDL handler in the Apps Script editor:`);
359
+ console.log(` https://script.google.com/d/${handlerScriptId}/edit`);
360
+ console.log(` Open the editor → Run any function → Approve permissions.`);
361
+ console.log(` This only needs to be done ONCE per Google account.`);
362
+ }
363
+
364
+ const ddlDeploymentId = ddlHandler.deploymentId;
365
+ const ddlAdminKey = ddlHandler.ddlAdminKey;
366
+
367
+ // ── Step 3: Create DB GAS projects for placeholders ──
368
+
369
+ step(3, 'Create DB GAS projects');
370
+
371
+ const placeholders = instances.filter(i => isPlaceholderScriptId(i.scriptId));
372
+ if (placeholders.length === 0) {
373
+ console.log(' All instances already have real scriptIds — skipping.');
374
+ } else {
375
+ console.log(` ${placeholders.length} placeholder(s) to create...`);
376
+
377
+ for (const inst of placeholders) {
378
+ const title = `${config.name}-${env}-${inst.type}`;
379
+ console.log(`\n Creating GAS project: ${title}`);
380
+
381
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nsa-sheets-db-builder-'));
382
+ try {
383
+ execSync(
384
+ `npx --prefer-offline @google/clasp create --type webapp --title "${title}" --rootDir .${userFlag(userProfile)}`,
385
+ { cwd: tmpDir, stdio: 'pipe' }
386
+ );
387
+
388
+ const claspJson = JSON.parse(fs.readFileSync(path.join(tmpDir, '.clasp.json'), 'utf8'));
389
+ const newScriptId = claspJson.scriptId;
390
+
391
+ if (!newScriptId) {
392
+ console.error(' ERROR: clasp create succeeded but no scriptId found.');
393
+ process.exit(1);
394
+ }
395
+
396
+ console.log(` scriptId: ${newScriptId}`);
397
+
398
+ const oldEntry = envConfig.instances[inst.scriptId];
399
+ delete envConfig.instances[inst.scriptId];
400
+ envConfig.instances[newScriptId] = oldEntry;
401
+
402
+ saveProjectConfig(dbName, config);
403
+ console.log(` Saved to project.json`);
404
+ } catch (err) {
405
+ const stderr = err.stderr?.toString() || '';
406
+ const stdout = err.stdout?.toString() || '';
407
+ console.error(`\n clasp create failed for ${inst.type}:`);
408
+ if (stderr) console.error(stderr);
409
+ if (stdout) console.error(stdout);
410
+ process.exit(1);
411
+ } finally {
412
+ fs.rmSync(tmpDir, { recursive: true, force: true });
413
+ }
414
+ }
415
+ }
416
+
417
+ // ── Step 4: Build DB instances ──
418
+
419
+ step(4, 'Build DB instances');
420
+
421
+ instances = getAllInstances(envConfig);
422
+
423
+ for (const inst of instances) {
424
+ buildInstance(dbName, config, env, envConfig, inst, features, rbacConfig, viewsConfig, customMethodsConfig);
425
+ }
426
+ console.log('\n Build complete.');
427
+
428
+ // ── Step 5: Push DB instances ──
429
+
430
+ step(5, 'Push DB instances');
431
+
432
+ for (const inst of instances) {
433
+ const distDir = getDistDirForInstance(dbName, inst.type);
434
+ console.log(`\n Pushing ${inst.type}...`);
435
+ try {
436
+ execSync(`npx --prefer-offline @google/clasp push --force${userFlag(userProfile)}`, {
437
+ cwd: distDir,
438
+ stdio: 'inherit'
439
+ });
440
+ } catch {
441
+ console.error(` clasp push failed for ${inst.type}.`);
442
+ process.exit(1);
443
+ }
444
+ }
445
+ console.log('\n Push complete.');
446
+
447
+ // ── Step 6: Deploy DB instances ──
448
+
449
+ step(6, 'Deploy DB instances');
450
+
451
+ for (const inst of instances) {
452
+ try {
453
+ deployInstance(inst, dbName, env, envConfig, userProfile, config);
454
+ } catch {
455
+ console.error(` Deploy failed for ${inst.type}.`);
456
+ process.exit(1);
457
+ }
458
+ }
459
+
460
+ // Refresh instances to get deploymentIds
461
+ config = loadDbConfig(dbName);
462
+ const { envConfig: deployedEnvConfig } = getEnvConfig(config, args.env);
463
+ instances = getAllInstances(deployedEnvConfig);
464
+
465
+ const primaryDeploymentId = instances[0].deploymentId;
466
+ if (!primaryDeploymentId) {
467
+ console.error(' ERROR: primary instance has no deploymentId after deploy.');
468
+ process.exit(1);
469
+ }
470
+ console.log(`\n Primary web app: https://script.google.com/macros/s/${primaryDeploymentId}/exec`);
471
+ console.log(' Deploy complete.');
472
+
473
+ // ── Step 7: Create Drive resources (via DDL handler) ──
474
+
475
+ step(7, 'Create Drive resources (via DDL handler)');
476
+
477
+ if (deployedEnvConfig.systemSpreadsheetId) {
478
+ console.log(` Already set up — systemSpreadsheetId: ${deployedEnvConfig.systemSpreadsheetId}`);
479
+ console.log(' Skipping Drive resource creation.');
480
+ } else {
481
+ const folderName = `${config.name}-${env}`;
482
+ console.log(` Creating system resources via DDL handler web app...`);
483
+
484
+ const setupResult = await callWebApp(ddlDeploymentId, 'ddlSetup', {
485
+ name: folderName,
486
+ driveFolderId: deployedEnvConfig.driveFolderId || undefined
487
+ }, ddlAdminKey);
488
+
489
+ if (setupResult?.error) {
490
+ console.error('\n ddlSetup error:', setupResult.error);
491
+ process.exit(1);
492
+ }
493
+ if (!setupResult?.systemSpreadsheetId) {
494
+ console.error('\n ddlSetup failed — no systemSpreadsheetId:', JSON.stringify(setupResult, null, 2));
495
+ process.exit(1);
496
+ }
497
+
498
+ deployedEnvConfig.driveFolderId = setupResult.folderId;
499
+ deployedEnvConfig.systemSpreadsheetId = setupResult.systemSpreadsheetId;
500
+ saveProjectConfig(dbName, config);
501
+
502
+ console.log(` Drive folder: ${setupResult.folderUrl}`);
503
+ console.log(` System spreadsheet: ${setupResult.systemSpreadsheetId}`);
504
+
505
+ // Create data spreadsheet for __new__ tables
506
+ const tables = loadTables(dbName);
507
+ const needsNew = Object.entries(tables).filter(([, t]) => t.spreadsheetId === '__new__');
508
+
509
+ if (needsNew.length > 0) {
510
+ const dataName = `${config.name}-data`;
511
+ console.log(`\n Creating data spreadsheet: ${dataName}`);
512
+
513
+ const dataResult = await callWebApp(ddlDeploymentId, 'ddlCreateDataSpreadsheet', {
514
+ name: dataName,
515
+ folderId: setupResult.folderId
516
+ }, ddlAdminKey);
517
+
518
+ if (dataResult?.error) {
519
+ console.error(' ddlCreateDataSpreadsheet error:', dataResult.error);
520
+ process.exit(1);
521
+ }
522
+
523
+ if (dataResult?.spreadsheetId) {
524
+ console.log(` Data spreadsheet: ${dataResult.spreadsheetId}`);
525
+
526
+ for (const [, table] of Object.entries(tables)) {
527
+ if (table.spreadsheetId === '__new__') {
528
+ table.spreadsheetId = dataResult.spreadsheetId;
529
+ }
530
+ }
531
+ saveTables(dbName, tables);
532
+ console.log(` Updated tables.json with real spreadsheet ID`);
533
+ } else {
534
+ console.error(' Data spreadsheet creation failed:', JSON.stringify(dataResult, null, 2));
535
+ process.exit(1);
536
+ }
537
+ }
538
+
539
+ // ── Step 8: Re-build + re-push + re-deploy ──
540
+
541
+ step(8, 'Re-build + re-push + re-deploy (config updated with Drive IDs)');
542
+
543
+ config = loadDbConfig(dbName);
544
+ const { envConfig: freshEnvConfig } = getEnvConfig(config, args.env);
545
+ instances = getAllInstances(freshEnvConfig);
546
+
547
+ for (const inst of instances) {
548
+ buildInstance(dbName, config, env, freshEnvConfig, inst, features, rbacConfig, viewsConfig, customMethodsConfig);
549
+ }
550
+
551
+ for (const inst of instances) {
552
+ const distDir = getDistDirForInstance(dbName, inst.type);
553
+ console.log(`\n Re-pushing ${inst.type}...`);
554
+ try {
555
+ execSync(`npx --prefer-offline @google/clasp push --force${userFlag(userProfile)}`, {
556
+ cwd: distDir,
557
+ stdio: 'inherit'
558
+ });
559
+ } catch {
560
+ console.error(` clasp push failed for ${inst.type} (re-push).`);
561
+ process.exit(1);
562
+ }
563
+ }
564
+
565
+ // Update deployments with new code
566
+ for (const inst of instances) {
567
+ try {
568
+ deployInstance(inst, dbName, env, freshEnvConfig, userProfile, config);
569
+ } catch {
570
+ console.error(` Re-deploy failed for ${inst.type}.`);
571
+ process.exit(1);
572
+ }
573
+ }
574
+ console.log('\n Re-build + re-push + re-deploy complete.');
575
+ }
576
+
577
+ // ── Step 9: Provision tables (via DDL handler) ──
578
+
579
+ step(9, 'Provision tables (via DDL handler)');
580
+
581
+ const tables = loadTables(dbName);
582
+ const spreadsheetIds = getDataSpreadsheetIds(tables);
583
+
584
+ if (spreadsheetIds.length === 0) {
585
+ console.error(' No data spreadsheets found in tables.json.');
586
+ console.error(' Drive resource creation may have failed.');
587
+ process.exit(1);
588
+ }
589
+
590
+ const spreadsheetId = spreadsheetIds[0];
591
+ const tableNames = Object.keys(tables);
592
+
593
+ console.log(` Provisioning ${tableNames.length} table(s): ${tableNames.join(', ')}`);
594
+ console.log(` Data spreadsheet: ${spreadsheetId}`);
595
+
596
+ const provisionResult = await callWebApp(ddlDeploymentId, 'ddlProvisionTables', {
597
+ spreadsheetId,
598
+ tables
599
+ }, ddlAdminKey);
600
+
601
+ if (provisionResult?.error) {
602
+ console.error(' Provision error:', provisionResult.error);
603
+ process.exit(1);
604
+ }
605
+ console.log(' Provision result:', JSON.stringify(provisionResult, null, 2));
606
+
607
+ // ── Step 10: Rotate API keys (via DB script web app) ──
608
+
609
+ step(10, 'Rotate API keys');
610
+
611
+ const apiKeys = {};
612
+
613
+ config = loadDbConfig(dbName);
614
+ const { envConfig: keyEnvConfig } = getEnvConfig(config, args.env);
615
+ instances = getAllInstances(keyEnvConfig);
616
+
617
+ const instanceDdlAdminKey = keyEnvConfig.ddlAdminKey;
618
+
619
+ for (const inst of instances) {
620
+ if (!inst.deploymentId) {
621
+ console.warn(` Skipping ${inst.type} — no deploymentId.`);
622
+ continue;
623
+ }
624
+ console.log(`\n Rotating API key for ${inst.type}...`);
625
+
626
+ const result = await callWebApp(inst.deploymentId, 'ddlRotateApiKey', {}, instanceDdlAdminKey);
627
+
628
+ if (result?.error) {
629
+ console.error(` ddlRotateApiKey error for ${inst.type}:`, result.error);
630
+ process.exit(1);
631
+ }
632
+ if (result?.key) {
633
+ apiKeys[inst.type] = result.key;
634
+ console.log(` Key set for ${inst.type}.`);
635
+ } else {
636
+ console.warn(` Warning: ddlRotateApiKey result:`, JSON.stringify(result, null, 2));
637
+ }
638
+ }
639
+
640
+ // ── Step 11: Summary ──
641
+
642
+ step(11, 'Summary');
643
+
644
+ config = loadDbConfig(dbName);
645
+ const { envConfig: finalEnvConfig } = getEnvConfig(config, args.env);
646
+
647
+ console.log(`\n Project: ${dbName}`);
648
+ console.log(` Environment: ${env}`);
649
+
650
+ // DDL handler info
651
+ console.log(`\n DDL Handler (${account}):`);
652
+ console.log(` Script: https://script.google.com/d/${ddlHandler.scriptId}/edit`);
653
+ console.log(` Web app: https://script.google.com/macros/s/${ddlHandler.deploymentId}/exec`);
654
+
655
+ if (finalEnvConfig.driveFolderId) {
656
+ console.log(`\n Drive folder: https://drive.google.com/drive/folders/${finalEnvConfig.driveFolderId}`);
657
+ }
658
+
659
+ console.log('\n Instances:');
660
+ for (const inst of getAllInstances(finalEnvConfig)) {
661
+ console.log(` ${inst.type}:`);
662
+ console.log(` Script: https://script.google.com/d/${inst.scriptId}/edit`);
663
+ if (inst.deploymentId) {
664
+ console.log(` Web app: https://script.google.com/macros/s/${inst.deploymentId}/exec`);
665
+ }
666
+ if (apiKeys[inst.type]) {
667
+ console.log(` API key: ${apiKeys[inst.type]}`);
668
+ }
669
+ }
670
+
671
+ console.log('\n API keys are shown above — store them securely.');
672
+ console.log(' They will not be shown again.\n');
673
+
674
+ console.log('Setup complete!');
675
+ }
676
+
677
+ setup();