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,1019 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Shared utilities for nsa-sheets-db-builder CLI scripts.
5
+ *
6
+ * Provides: arg parsing, config I/O, env resolution,
7
+ * clasp run wrapper, schema extraction, auth enforcement,
8
+ * and .env file loading.
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import https from 'https';
14
+ import { execSync, spawnSync } from 'child_process';
15
+ import { fileURLToPath } from 'url';
16
+
17
+ // ── Paths ────────────────────────────────
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+
21
+ /**
22
+ * PACKAGE_ROOT: where libs/, src/, templates/ live.
23
+ * When running locally (npm run): same as PROJECT_ROOT.
24
+ * When running via npx: npm cache / global install dir.
25
+ */
26
+ export const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
27
+
28
+ /**
29
+ * PROJECT_ROOT: where dbs/, dist/, .env files live (user's working directory).
30
+ * When running locally: same as PACKAGE_ROOT.
31
+ * When running via npx: process.cwd().
32
+ */
33
+ export const PROJECT_ROOT = process.env.NSA_SHEETS_DB_BUILDER_ROOT || PACKAGE_ROOT;
34
+
35
+ /**
36
+ * COMMON_LIBS_DIR: path to common (shared) GAS libraries.
37
+ * Bundled inside the package at libs/common/.
38
+ */
39
+ export const COMMON_LIBS_DIR = path.join(PACKAGE_ROOT, 'libs', 'common');
40
+
41
+ // Keep ROOT as alias for backward compat
42
+ export const ROOT = PACKAGE_ROOT;
43
+
44
+ export const DBS_DIR = path.join(PROJECT_ROOT, 'dbs');
45
+ export const DIST_DIR = path.join(PROJECT_ROOT, 'dist');
46
+
47
+ // Root project config — shared across all projects under this installation
48
+ const ROOT_CONFIG_PATH = path.join(PROJECT_ROOT, '.nsaproject.json');
49
+
50
+ // DDL Handler dist directory
51
+ const DDL_HANDLER_DIST_NAME = '__ddl-handler__';
52
+
53
+ // ── Arg Parsing ──────────────────────────
54
+
55
+ /**
56
+ * Parse CLI args into a flat object.
57
+ * Supports: --db name, --env dev, --table users, --push, --deploy, --status,
58
+ * --inherit, --force, --minimal, --libs a,b,c, --key val, --hours n
59
+ */
60
+ export function parseArgs(argv = process.argv.slice(2)) {
61
+ const args = {};
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const arg = argv[i];
64
+ if (arg.startsWith('--')) {
65
+ const key = arg.slice(2);
66
+ const next = argv[i + 1];
67
+ if (next && !next.startsWith('--')) {
68
+ args[key] = next;
69
+ i++;
70
+ } else {
71
+ args[key] = true;
72
+ }
73
+ }
74
+ }
75
+ return args;
76
+ }
77
+
78
+ /**
79
+ * Require --db flag or exit with usage message.
80
+ */
81
+ export function requireDb(args, scriptName) {
82
+ if (!args.db) {
83
+ console.error(`Usage: node scripts/${scriptName} --db <name> [--env <env>]`);
84
+ process.exit(1);
85
+ }
86
+ if (!/^[a-z0-9][a-z0-9_-]*$/i.test(args.db)) {
87
+ console.error(`Invalid DB name: "${args.db}"`);
88
+ console.error('Must contain only letters, numbers, hyphens, and underscores.');
89
+ process.exit(1);
90
+ }
91
+ return args.db;
92
+ }
93
+
94
+ // ── Config I/O ───────────────────────────
95
+ //
96
+ // Project layout:
97
+ // dbs/<name>/project.json — name, settings, features, environments (all-in-one)
98
+ // dbs/<name>/tables.json — table definitions (schema)
99
+ // dbs/<name>/rbac.json — RBAC capabilities + privacy filters (if enabled)
100
+ // dbs/<name>/views.json — SQL view definitions (if enabled)
101
+ // dbs/<name>/customMethods.json — custom method declarations
102
+ // dbs/<name>/libs/ — scaffolded library source files
103
+ // dbs/<name>/overrides/ — custom method handler files
104
+
105
+ export function getProjectConfigPath(dbName) {
106
+ return path.join(DBS_DIR, dbName, 'project.json');
107
+ }
108
+
109
+ export function getTablesPath(dbName) {
110
+ return path.join(DBS_DIR, dbName, 'tables.json');
111
+ }
112
+
113
+ export function getLibsDir(dbName) {
114
+ return path.join(DBS_DIR, dbName, 'libs');
115
+ }
116
+
117
+ export function getOverridesDir(dbName) {
118
+ return path.join(DBS_DIR, dbName, 'overrides');
119
+ }
120
+
121
+ export function getRbacConfigPath(dbName) {
122
+ return path.join(DBS_DIR, dbName, 'rbac.json');
123
+ }
124
+
125
+ export function getViewsConfigPath(dbName) {
126
+ return path.join(DBS_DIR, dbName, 'views.json');
127
+ }
128
+
129
+ export function getCustomMethodsConfigPath(dbName) {
130
+ return path.join(DBS_DIR, dbName, 'customMethods.json');
131
+ }
132
+
133
+ /**
134
+ * Load project.json — exits if not found.
135
+ * This is the single source of truth: name, settings, features, environments.
136
+ */
137
+ export function loadProjectConfig(dbName) {
138
+ const p = getProjectConfigPath(dbName);
139
+ if (!fs.existsSync(p)) {
140
+ console.error(`project.json not found: ${p}`);
141
+ process.exit(1);
142
+ }
143
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
144
+ }
145
+
146
+ /**
147
+ * Alias for loadProjectConfig — all config lives in project.json.
148
+ */
149
+ export function loadDbConfig(dbName) {
150
+ return loadProjectConfig(dbName);
151
+ }
152
+
153
+ /**
154
+ * Save project.json (all config: name, settings, features, environments).
155
+ */
156
+ export function saveProjectConfig(dbName, config) {
157
+ const p = getProjectConfigPath(dbName);
158
+ fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n');
159
+ }
160
+
161
+ /**
162
+ * Load tables.json — exits if not found.
163
+ */
164
+ export function loadTables(dbName) {
165
+ const p = getTablesPath(dbName);
166
+ if (!fs.existsSync(p)) {
167
+ console.error(`tables.json not found: ${p}`);
168
+ process.exit(1);
169
+ }
170
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
171
+ }
172
+
173
+ /**
174
+ * Save tables.json.
175
+ */
176
+ export function saveTables(dbName, tables) {
177
+ const p = getTablesPath(dbName);
178
+ fs.writeFileSync(p, JSON.stringify(tables, null, 2) + '\n');
179
+ }
180
+
181
+ /**
182
+ * Load rbac.json — returns null if not found (feature may be disabled).
183
+ */
184
+ export function loadRbacConfig(dbName) {
185
+ const p = getRbacConfigPath(dbName);
186
+ if (!fs.existsSync(p)) return null;
187
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
188
+ }
189
+
190
+ /**
191
+ * Save rbac.json.
192
+ */
193
+ export function saveRbacConfig(dbName, rbacConfig) {
194
+ const p = getRbacConfigPath(dbName);
195
+ fs.writeFileSync(p, JSON.stringify(rbacConfig, null, 2) + '\n');
196
+ }
197
+
198
+ /**
199
+ * Load views.json — returns null if not found (feature may be disabled).
200
+ */
201
+ export function loadViewsConfig(dbName) {
202
+ const p = getViewsConfigPath(dbName);
203
+ if (!fs.existsSync(p)) return null;
204
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
205
+ }
206
+
207
+ /**
208
+ * Save views.json.
209
+ */
210
+ export function saveViewsConfig(dbName, viewsConfig) {
211
+ const p = getViewsConfigPath(dbName);
212
+ fs.writeFileSync(p, JSON.stringify(viewsConfig, null, 2) + '\n');
213
+ }
214
+
215
+ /**
216
+ * Load customMethods.json — returns empty object if not found.
217
+ */
218
+ export function loadCustomMethodsConfig(dbName) {
219
+ const p = getCustomMethodsConfigPath(dbName);
220
+ if (!fs.existsSync(p)) return {};
221
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
222
+ }
223
+
224
+ /**
225
+ * Get project features + auth config from project.json.
226
+ * Returns { authMode, rbac: { enabled }, views: { enabled } }.
227
+ */
228
+ export function getFeatures(projectConfig) {
229
+ const features = projectConfig.features || {};
230
+ return {
231
+ authMode: projectConfig.authMode || 'noAuth',
232
+ rbac: {
233
+ enabled: !!features.rbac?.enabled
234
+ },
235
+ views: {
236
+ enabled: !!features.views?.enabled
237
+ }
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Validate RBAC config against tables.json.
243
+ * Ensures users and roles tables exist when RBAC is enabled.
244
+ */
245
+ export function validateRbacRequirements(tables, rbacConfig) {
246
+ const errors = [];
247
+ if (!tables.users) {
248
+ errors.push('RBAC requires a "users" table in tables.json');
249
+ }
250
+ if (!tables.roles) {
251
+ errors.push('RBAC requires a "roles" table in tables.json');
252
+ }
253
+ if (!rbacConfig.roles || Object.keys(rbacConfig.roles).length === 0) {
254
+ errors.push('rbac.json must define at least one role');
255
+ }
256
+ if (!rbacConfig.defaultRole) {
257
+ errors.push('rbac.json must define a defaultRole');
258
+ }
259
+ return errors;
260
+ }
261
+
262
+ /**
263
+ * Resolve environment config — uses --env flag or defaults to 'dev'.
264
+ * Returns { env, envConfig } where envConfig has instances, account, spreadsheetId.
265
+ */
266
+ export function getEnvConfig(config, envOverride) {
267
+ const env = envOverride || 'dev';
268
+ const envConfig = config.environments?.[env];
269
+ if (!envConfig) {
270
+ console.error(`Environment "${env}" not found in config. Available: ${Object.keys(config.environments || {}).join(', ')}`);
271
+ process.exit(1);
272
+ }
273
+ return { env, envConfig };
274
+ }
275
+
276
+ /**
277
+ * Get unique data spreadsheet IDs from the schema.
278
+ * Tables with spreadsheetId "__new__" are excluded (not yet created).
279
+ *
280
+ * @param {object} schema - Parsed DB_SCHEMA object
281
+ * @returns {string[]} Array of unique spreadsheet IDs
282
+ */
283
+ export function getDataSpreadsheetIds(schema) {
284
+ const ids = new Set();
285
+ for (const table of Object.values(schema)) {
286
+ if (table.spreadsheetId && table.spreadsheetId !== '__new__') {
287
+ ids.add(table.spreadsheetId);
288
+ }
289
+ }
290
+ return [...ids];
291
+ }
292
+
293
+ /**
294
+ * Require at least one data spreadsheet in the schema or exit.
295
+ */
296
+ export function requireDataSpreadsheet(schema, dbName) {
297
+ const ids = getDataSpreadsheetIds(schema);
298
+ if (ids.length === 0) {
299
+ console.error(`No data spreadsheets set in schema for "${dbName}".`);
300
+ console.error('Run "nsa-sheets-db-builder create -- --db <name>" first.');
301
+ process.exit(1);
302
+ }
303
+ return ids[0];
304
+ }
305
+
306
+ // ── Dist Dir ─────────────────────────────
307
+
308
+ /**
309
+ * Return the dist directory for a given DB name.
310
+ */
311
+ export function getDistDir(dbName) {
312
+ return path.join(DIST_DIR, dbName);
313
+ }
314
+
315
+ /**
316
+ * Return the dist directory for a specific instance.
317
+ * Layout: dist/<db>/<instance-type>/
318
+ */
319
+ export function getDistDirForInstance(dbName, instanceType) {
320
+ return path.join(DIST_DIR, dbName, instanceType);
321
+ }
322
+
323
+ // ── Instance Helpers ─────────────────────
324
+ //
325
+ // Config shape: instances keyed by scriptId, value has type + deploymentId.
326
+ // "instances": { "SCRIPT_ID": { "type": "read-write", "deploymentId": "" } }
327
+ //
328
+ // --instance flag accepts a short alias (rw, r, w), full type, or scriptId.
329
+
330
+ /** Short aliases → canonical instance type */
331
+ export const INSTANCE_TYPE_ALIASES = {
332
+ 'rw': 'read-write',
333
+ 'r': 'read-only',
334
+ 'w': 'write-only',
335
+ 'read-write': 'read-write',
336
+ 'read-only': 'read-only',
337
+ 'write-only': 'write-only'
338
+ };
339
+
340
+ export function resolveInstanceType(input) {
341
+ const canonical = INSTANCE_TYPE_ALIASES[input];
342
+ if (!canonical) {
343
+ console.error(`Invalid instance type: "${input}"`);
344
+ console.error('Valid types: rw (read-write), r (read-only), w (write-only)');
345
+ process.exit(1);
346
+ }
347
+ return canonical;
348
+ }
349
+
350
+ /**
351
+ * Get all instances for an environment config.
352
+ * Each entry: { scriptId, type, deploymentId }
353
+ *
354
+ * @param {object} envConfig - Environment config
355
+ * @returns {Array<{ scriptId: string, type: string, deploymentId: string }>}
356
+ */
357
+ export function getAllInstances(envConfig) {
358
+ if (!envConfig.instances) return [];
359
+ return Object.entries(envConfig.instances).map(([scriptId, config]) => ({
360
+ scriptId,
361
+ type: config.type || 'read-write',
362
+ deploymentId: config.deploymentId || ''
363
+ }));
364
+ }
365
+
366
+ /**
367
+ * Resolve a single instance by type or scriptId.
368
+ *
369
+ * @param {object} envConfig - Environment config
370
+ * @param {string} identifier - Instance type (e.g. "reader-writer") or scriptId
371
+ * @returns {{ scriptId: string, type: string, deploymentId: string }}
372
+ */
373
+ export function getInstanceConfig(envConfig, identifier) {
374
+ const instances = getAllInstances(envConfig);
375
+
376
+ // Try exact scriptId match first
377
+ const byId = instances.find(i => i.scriptId === identifier);
378
+ if (byId) return byId;
379
+
380
+ // Resolve alias (rw → read-write, r → read-only, w → write-only)
381
+ const resolved = INSTANCE_TYPE_ALIASES[identifier] || identifier;
382
+
383
+ // Try type match
384
+ const byType = instances.filter(i => i.type === resolved);
385
+ if (byType.length === 1) return byType[0];
386
+ if (byType.length > 1) {
387
+ console.error(`Multiple instances of type "${identifier}". Use the scriptId instead:`);
388
+ for (const inst of byType) {
389
+ console.error(` ${inst.scriptId}`);
390
+ }
391
+ process.exit(1);
392
+ }
393
+
394
+ const available = instances.map(i => `${i.type} (${i.scriptId.slice(0, 12)}...)`).join(', ');
395
+ console.error(`Instance "${identifier}" not found. Available: ${available}`);
396
+ process.exit(1);
397
+ }
398
+
399
+ /**
400
+ * Require an --instance flag or auto-select if only one instance exists.
401
+ *
402
+ * @param {object} args - Parsed CLI args
403
+ * @param {object} envConfig - Environment config
404
+ * @returns {{ scriptId: string, type: string, deploymentId: string }}
405
+ */
406
+ export function requireInstance(args, envConfig) {
407
+ const instances = getAllInstances(envConfig);
408
+
409
+ if (instances.length === 0) {
410
+ console.error('No instances configured for this environment.');
411
+ console.error('Add a scriptId to instances in api.json.');
412
+ process.exit(1);
413
+ }
414
+
415
+ // If --instance flag provided, resolve by type or scriptId
416
+ if (args.instance) {
417
+ return getInstanceConfig(envConfig, args.instance);
418
+ }
419
+
420
+ // Auto-select if only one instance
421
+ if (instances.length === 1) {
422
+ return instances[0];
423
+ }
424
+
425
+ // Multiple instances, no --instance flag
426
+ console.error('Multiple instances found:');
427
+ for (const inst of instances) {
428
+ console.error(` ${inst.type} → ${inst.scriptId.slice(0, 20)}...`);
429
+ }
430
+ console.error('Use --instance <type> to specify which one.');
431
+ process.exit(1);
432
+ }
433
+
434
+ // ── Clasp Credentials (clasp 3.x --user profiles) ──
435
+
436
+ /**
437
+ * Get the clasp user profile name for a DB project + environment.
438
+ * Format: "<dbName>--<env>" (e.g., "nostackapps-cms--dev")
439
+ *
440
+ * @param {string} dbName - DB project name
441
+ * @param {string} env - Environment name
442
+ * @returns {string} Profile name for clasp --user
443
+ */
444
+ export function getClaspUserProfile(dbName, env = 'dev') {
445
+ return `${dbName}--${env}`;
446
+ }
447
+
448
+ /**
449
+ * Path to the global .clasprc.json where clasp 3.x stores all named profiles.
450
+ */
451
+ export function getClasprcPath() {
452
+ return path.join(process.env.HOME || '~', '.clasprc.json');
453
+ }
454
+
455
+ /**
456
+ * Load and parse .clasprc.json. Returns null on failure.
457
+ */
458
+ function loadClasprc() {
459
+ const clasprcPath = getClasprcPath();
460
+ if (!fs.existsSync(clasprcPath)) return null;
461
+ try {
462
+ return JSON.parse(fs.readFileSync(clasprcPath, 'utf8'));
463
+ } catch {
464
+ return null;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Check if clasp credentials exist for a profile.
470
+ * Handles both clasp 3.x named profiles (data.tokens.<profile>)
471
+ * and legacy global format (data.token at root, treated as "default").
472
+ *
473
+ * @param {string} profile - The profile name (e.g., "nostackapps-cms--dev" or "default")
474
+ * @returns {boolean}
475
+ */
476
+ export function hasClaspProfile(profile) {
477
+ const data = loadClasprc();
478
+ if (!data) return false;
479
+
480
+ // Clasp 3.x named profiles
481
+ if (data.tokens?.[profile]) return true;
482
+
483
+ // Legacy global format — root-level token counts as "default"
484
+ if (profile === 'default' && data.token?.access_token) return true;
485
+
486
+ return false;
487
+ }
488
+
489
+ /**
490
+ * Check if any clasp credentials exist (named or global).
491
+ * Returns the best available profile name, or null.
492
+ */
493
+ export function findAvailableClaspProfile(preferredProfile) {
494
+ const data = loadClasprc();
495
+ if (!data) return null;
496
+
497
+ // Preferred named profile
498
+ if (data.tokens?.[preferredProfile]) return preferredProfile;
499
+
500
+ // Legacy global token → use "default"
501
+ if (data.token?.access_token) return 'default';
502
+
503
+ // Any named profile at all
504
+ if (data.tokens) {
505
+ const names = Object.keys(data.tokens);
506
+ if (names.length > 0) return names[0];
507
+ }
508
+
509
+ return null;
510
+ }
511
+
512
+ /**
513
+ * Get the access_token for a profile from .clasprc.json.
514
+ * Handles both clasp 3.x named profiles and legacy global format.
515
+ *
516
+ * @param {string} profile - The profile name
517
+ * @returns {string|null} Access token or null
518
+ */
519
+ function getProfileAccessToken(profile) {
520
+ const data = loadClasprc();
521
+ if (!data) return null;
522
+
523
+ // Clasp 3.x named profiles
524
+ if (data.tokens?.[profile]?.access_token) {
525
+ return data.tokens[profile].access_token;
526
+ }
527
+
528
+ // Legacy global format — root-level token counts as "default"
529
+ if (profile === 'default' && data.token?.access_token) {
530
+ return data.token.access_token;
531
+ }
532
+
533
+ return null;
534
+ }
535
+
536
+ /**
537
+ * Get the full token record for a profile (access_token, refresh_token, clientId, etc).
538
+ * Handles both named profiles and legacy global format.
539
+ *
540
+ * @param {string} profile - The profile name
541
+ * @returns {{ accessToken: string, refreshToken: string, clientId: string, clientSecret: string } | null}
542
+ */
543
+ function getProfileTokenRecord(profile) {
544
+ const data = loadClasprc();
545
+ if (!data) return null;
546
+
547
+ // Clasp 3.x named profiles
548
+ const namedToken = data.tokens?.[profile];
549
+ if (namedToken?.access_token) {
550
+ const oauth = namedToken.oauth2ClientSettings || data.oauth2ClientSettings || {};
551
+ return {
552
+ accessToken: namedToken.access_token,
553
+ refreshToken: namedToken.refresh_token,
554
+ clientId: oauth.clientId,
555
+ clientSecret: oauth.clientSecret
556
+ };
557
+ }
558
+
559
+ // Legacy global format
560
+ if (profile === 'default' && data.token?.access_token) {
561
+ const oauth = data.oauth2ClientSettings || {};
562
+ return {
563
+ accessToken: data.token.access_token,
564
+ refreshToken: data.token.refresh_token,
565
+ clientId: oauth.clientId,
566
+ clientSecret: oauth.clientSecret
567
+ };
568
+ }
569
+
570
+ return null;
571
+ }
572
+
573
+ /**
574
+ * Refresh an expired access_token using the refresh_token.
575
+ * Updates .clasprc.json in place with the new token.
576
+ *
577
+ * @param {string} profile - The profile name
578
+ * @returns {Promise<string|null>} New access token or null on failure
579
+ */
580
+ function refreshAccessToken(profile) {
581
+ return new Promise((resolve) => {
582
+ const record = getProfileTokenRecord(profile);
583
+ if (!record?.refreshToken || !record?.clientId || !record?.clientSecret) {
584
+ resolve(null);
585
+ return;
586
+ }
587
+
588
+ const postData = new URLSearchParams({
589
+ client_id: record.clientId,
590
+ client_secret: record.clientSecret,
591
+ refresh_token: record.refreshToken,
592
+ grant_type: 'refresh_token'
593
+ }).toString();
594
+
595
+ const req = https.request('https://oauth2.googleapis.com/token', {
596
+ method: 'POST',
597
+ headers: {
598
+ 'Content-Type': 'application/x-www-form-urlencoded',
599
+ 'Content-Length': Buffer.byteLength(postData)
600
+ }
601
+ }, (res) => {
602
+ let body = '';
603
+ res.on('data', (chunk) => body += chunk);
604
+ res.on('end', () => {
605
+ try {
606
+ const result = JSON.parse(body);
607
+ if (!result.access_token) {
608
+ resolve(null);
609
+ return;
610
+ }
611
+
612
+ // Update .clasprc.json with new token
613
+ const clasprcPath = getClasprcPath();
614
+ const data = loadClasprc();
615
+ if (data) {
616
+ const newExpiry = Date.now() + (result.expires_in || 3600) * 1000;
617
+ if (data.tokens?.[profile]) {
618
+ data.tokens[profile].access_token = result.access_token;
619
+ data.tokens[profile].expiry_date = newExpiry;
620
+ } else if (profile === 'default' && data.token) {
621
+ data.token.access_token = result.access_token;
622
+ data.token.expiry_date = newExpiry;
623
+ }
624
+ fs.writeFileSync(clasprcPath, JSON.stringify(data) + '\n');
625
+ }
626
+
627
+ resolve(result.access_token);
628
+ } catch {
629
+ resolve(null);
630
+ }
631
+ });
632
+ });
633
+
634
+ req.on('error', () => resolve(null));
635
+ req.setTimeout(10000, () => { req.destroy(); resolve(null); });
636
+ req.write(postData);
637
+ req.end();
638
+ });
639
+ }
640
+
641
+ /**
642
+ * Get a valid access token for a profile, refreshing if expired.
643
+ *
644
+ * @param {string} profile - The profile name
645
+ * @returns {Promise<string|null>} Valid access token or null
646
+ */
647
+ async function getValidAccessToken(profile) {
648
+ const token = getProfileAccessToken(profile);
649
+ if (!token) return null;
650
+
651
+ // Try the existing token first
652
+ const email = await fetchEmail(token);
653
+ if (email) return token;
654
+
655
+ // Token likely expired — try refresh
656
+ const newToken = await refreshAccessToken(profile);
657
+ return newToken;
658
+ }
659
+
660
+ /**
661
+ * Fetch email using the Drive API (works with clasp's default scopes).
662
+ * Falls back to userinfo API if Drive fails.
663
+ *
664
+ * @param {string} accessToken
665
+ * @returns {Promise<string|null>}
666
+ */
667
+ function fetchEmail(accessToken) {
668
+ return new Promise((resolve) => {
669
+ // Drive about endpoint — works with drive.file scope that clasp always has
670
+ const req = https.get('https://www.googleapis.com/drive/v3/about?fields=user', {
671
+ headers: { 'Authorization': `Bearer ${accessToken}` }
672
+ }, (res) => {
673
+ let body = '';
674
+ res.on('data', (chunk) => body += chunk);
675
+ res.on('end', () => {
676
+ try {
677
+ const info = JSON.parse(body);
678
+ resolve(info.user?.emailAddress || null);
679
+ } catch {
680
+ resolve(null);
681
+ }
682
+ });
683
+ });
684
+ req.on('error', () => resolve(null));
685
+ req.setTimeout(5000, () => { req.destroy(); resolve(null); });
686
+ });
687
+ }
688
+
689
+ /**
690
+ * Get the logged-in email for a clasp user profile.
691
+ * Automatically refreshes expired tokens.
692
+ *
693
+ * @param {string} profile - The profile name
694
+ * @returns {Promise<string|null>} Email address or null on failure
695
+ */
696
+ export async function getAccountEmail(profile) {
697
+ const token = await getValidAccessToken(profile);
698
+ if (!token) return null;
699
+ return fetchEmail(token);
700
+ }
701
+
702
+ /**
703
+ * Resolve the account for an environment.
704
+ * Checks DB envConfig first, then root config (.nsaproject.json).
705
+ *
706
+ * @param {object} envConfig - DB environment config
707
+ * @param {string} env - Environment name
708
+ * @returns {string} Account email or empty string
709
+ */
710
+ export function resolveAccount(envConfig, env) {
711
+ if (envConfig.account) return envConfig.account;
712
+ const rootEnv = getRootEnvConfig(env);
713
+ return rootEnv.account || '';
714
+ }
715
+
716
+ /**
717
+ * Validate that the logged-in account matches the expected account for the env.
718
+ *
719
+ * @param {string} profile - clasp user profile name
720
+ * @param {object} envConfig - Environment config (may have .account)
721
+ * @param {string} env - Environment name (for root config lookup)
722
+ * @returns {Promise<{ valid: boolean, email?: string, reason?: string }>}
723
+ */
724
+ export async function validateAccount(profile, envConfig, env) {
725
+ const email = await getAccountEmail(profile);
726
+ if (!email) {
727
+ return { valid: false, reason: 'Could not determine logged-in account (token may be expired)' };
728
+ }
729
+
730
+ const expected = resolveAccount(envConfig, env);
731
+ if (expected && email !== expected) {
732
+ return {
733
+ valid: false,
734
+ email,
735
+ reason: `Account mismatch: expected "${expected}" but logged in as "${email}"`
736
+ };
737
+ }
738
+
739
+ return { valid: true, email };
740
+ }
741
+
742
+ /**
743
+ * Ensure the user is authenticated for a DB project + environment.
744
+ *
745
+ * Resolution order:
746
+ * 1. --inherit flag → use "default" profile
747
+ * 2. Named profile (dbName--env) → use if exists
748
+ * 3. Fall back to global/default credentials → use if account matches
749
+ *
750
+ * If account is declared in envConfig, validates the logged-in email matches.
751
+ * If account is empty, stores the detected email for future validation.
752
+ * Blocks on missing credentials or account mismatch (unless --force).
753
+ *
754
+ * Returns the clasp --user flag value: a profile name for named profiles,
755
+ * or empty string for default/legacy credentials (caller should omit --user).
756
+ *
757
+ * @param {string} dbName - DB project name
758
+ * @param {object} args - Parsed CLI args
759
+ * @param {object} envConfig - Environment config
760
+ * @param {string} env - Environment name
761
+ * @returns {Promise<string>} The clasp --user value (empty string = use default)
762
+ */
763
+ export async function ensureAuthenticated(dbName, args, envConfig, env) {
764
+ // --inherit flag: use "default" clasp profile (global login)
765
+ let profile = args.inherit ? 'default' : getClaspUserProfile(dbName, env);
766
+
767
+ // If named profile not found, try falling back to any available credentials
768
+ if (!hasClaspProfile(profile)) {
769
+ const fallback = findAvailableClaspProfile(profile);
770
+ if (fallback) {
771
+ console.log(` Named profile "${profile}" not found — falling back to "${fallback}" credentials.`);
772
+ profile = fallback;
773
+ } else {
774
+ console.error(`\nNo clasp credentials found.`);
775
+ console.error(`Run: npm run login -- --db ${dbName} --env ${env}`);
776
+ process.exit(1);
777
+ }
778
+ }
779
+
780
+ // Validate account
781
+ if (!args.force) {
782
+ const validation = await validateAccount(profile, envConfig, env);
783
+ if (!validation.valid) {
784
+ console.error(`\nAuth error: ${validation.reason}`);
785
+ if (validation.reason?.includes('mismatch')) {
786
+ console.error(`Logged-in account does not match the declared account for this environment.`);
787
+ console.error(`Run: npm run login -- --db ${dbName} --env ${env}`);
788
+ } else if (validation.reason?.includes('expired')) {
789
+ console.error(`Run: npm run login -- --db ${dbName} --env ${env}`);
790
+ }
791
+ process.exit(1);
792
+ }
793
+
794
+ // Store email in root config if account field was empty
795
+ const knownAccount = resolveAccount(envConfig, env);
796
+ if (!knownAccount && validation.email) {
797
+ const rootConfig = loadRootConfig();
798
+ if (!rootConfig.environments) rootConfig.environments = {};
799
+ if (!rootConfig.environments[env]) rootConfig.environments[env] = {};
800
+ rootConfig.environments[env].account = validation.email;
801
+ saveRootConfig(rootConfig);
802
+ console.log(` Stored account "${validation.email}" in .nsaproject.json`);
803
+ }
804
+
805
+ console.log(` Using account: ${validation.email} (profile: ${profile})`);
806
+ } else {
807
+ console.log(` Using profile: ${profile} (--force, skipping validation)`);
808
+ }
809
+
810
+ // Return empty string for "default" profile — caller should omit --user flag
811
+ return profile === 'default' ? '' : profile;
812
+ }
813
+
814
+ // ── Clasp Run ────────────────────────────
815
+
816
+ /**
817
+ * Execute `clasp run <functionName>` from distDir.
818
+ * Uses clasp 3.x --user flag for credential selection.
819
+ * Parses JSON result from clasp stdout.
820
+ *
821
+ * @param {string} distDir - dist/<db-name>/ directory (must contain .clasp.json)
822
+ * @param {string} functionName - GAS function to call
823
+ * @param {Array} params - Array of params (will be JSON-stringified)
824
+ * @param {object} opts - Options: { userProfile }
825
+ * @returns {any} Parsed result from clasp run
826
+ */
827
+ export function claspRun(distDir, functionName, params = [], { userProfile } = {}) {
828
+ if (!fs.existsSync(path.join(distDir, '.clasp.json'))) {
829
+ console.error(`No .clasp.json in ${distDir} — run "npm run build -- --db <name>" first.`);
830
+ process.exit(1);
831
+ }
832
+
833
+ const paramsJson = JSON.stringify(params);
834
+ const userFlag = userProfile ? ` --user ${userProfile}` : '';
835
+ const cmd = `npx --prefer-offline @google/clasp run ${functionName} --params '${paramsJson}'${userFlag}`;
836
+
837
+ console.log(` Running: clasp run ${functionName}`);
838
+
839
+ const execOpts = {
840
+ cwd: distDir,
841
+ encoding: 'utf8',
842
+ stdio: ['pipe', 'pipe', 'pipe']
843
+ };
844
+
845
+ try {
846
+ const result = spawnSync(
847
+ 'npx', ['--prefer-offline', '@google/clasp', 'run', functionName, '--params', paramsJson, ...(userProfile ? ['--user', userProfile] : [])],
848
+ { cwd: distDir, encoding: 'utf8', timeout: 120000 }
849
+ );
850
+
851
+ const stdout = (result.stdout || '').trim();
852
+ const stderr = (result.stderr || '').trim();
853
+
854
+ if (result.signal) {
855
+ console.error(`\nclasp run killed by ${result.signal} (timeout?)`);
856
+ if (stderr) console.error(stderr);
857
+ if (stdout) console.error(stdout);
858
+ process.exit(1);
859
+ }
860
+
861
+ if (stderr) {
862
+ console.log(` stderr: ${stderr.split('\n')[0]}`);
863
+ }
864
+
865
+ if (result.status !== 0) {
866
+ console.error(`\nclasp run failed (exit ${result.status}):`);
867
+ if (stderr) console.error(stderr);
868
+ if (stdout) console.error(stdout);
869
+ process.exit(1);
870
+ }
871
+
872
+ // clasp run outputs the result on the last non-empty line
873
+ const lines = stdout.split('\n').filter(l => l.trim());
874
+ const lastLine = lines[lines.length - 1];
875
+
876
+ if (!lastLine) {
877
+ return stdout;
878
+ }
879
+
880
+ try {
881
+ return JSON.parse(lastLine);
882
+ } catch {
883
+ // If last line isn't JSON, return the full output
884
+ return stdout;
885
+ }
886
+ } catch (err) {
887
+ console.error(`\nclasp run error: ${err.message}`);
888
+ process.exit(1);
889
+ }
890
+ }
891
+
892
+ // ── Placeholder Detection ────────────────
893
+
894
+ /**
895
+ * Check if a scriptId is a placeholder (not yet created via clasp create).
896
+ */
897
+ export function isPlaceholderScriptId(scriptId) {
898
+ return !scriptId || scriptId.startsWith('YOUR_');
899
+ }
900
+
901
+ // ── .env File Support ────────────────────
902
+
903
+ /**
904
+ * Load a .env.<env> file from the project root.
905
+ * Simple KEY=VALUE parser (strips comments, handles quotes).
906
+ *
907
+ * @param {string} env - Environment name (e.g. 'dev', 'prod')
908
+ * @returns {object} Key-value pairs, empty object if file missing
909
+ */
910
+ export function loadEnvFile(env) {
911
+ const envPath = path.join(PROJECT_ROOT, `.env.${env}`);
912
+ if (!fs.existsSync(envPath)) {
913
+ return {};
914
+ }
915
+
916
+ const content = fs.readFileSync(envPath, 'utf8');
917
+ const result = {};
918
+
919
+ for (const line of content.split('\n')) {
920
+ const trimmed = line.trim();
921
+ if (!trimmed || trimmed.startsWith('#')) continue;
922
+
923
+ const eqIdx = trimmed.indexOf('=');
924
+ if (eqIdx === -1) continue;
925
+
926
+ const key = trimmed.slice(0, eqIdx).trim();
927
+ let value = trimmed.slice(eqIdx + 1).trim();
928
+
929
+ // Strip surrounding quotes
930
+ if ((value.startsWith('"') && value.endsWith('"')) ||
931
+ (value.startsWith("'") && value.endsWith("'"))) {
932
+ value = value.slice(1, -1);
933
+ }
934
+
935
+ result[key] = value;
936
+ }
937
+
938
+ return result;
939
+ }
940
+
941
+ /**
942
+ * Get environment defaults from .env file.
943
+ * These serve as fallbacks for project/api config values.
944
+ *
945
+ * @param {string} env - Environment name
946
+ * @returns {{ account?: string, driveFolderId?: string, loggingVerbosity?: number }}
947
+ */
948
+ export function getEnvDefaults(env) {
949
+ const envVars = loadEnvFile(env);
950
+ const defaults = {};
951
+
952
+ if (envVars.DEFAULT_ACCOUNT) defaults.account = envVars.DEFAULT_ACCOUNT;
953
+ if (envVars.DRIVE_ROOT_FOLDER_ID) defaults.driveFolderId = envVars.DRIVE_ROOT_FOLDER_ID;
954
+ if (envVars.DEFAULT_LOGGING_VERBOSITY) defaults.loggingVerbosity = parseInt(envVars.DEFAULT_LOGGING_VERBOSITY, 10);
955
+ if (envVars.DDL_API_KEY) defaults.ddlApiKey = envVars.DDL_API_KEY;
956
+
957
+ return defaults;
958
+ }
959
+
960
+ // ── Root Config (.nsaproject.json) ───────
961
+
962
+ /**
963
+ * Path to the root project config file.
964
+ */
965
+ export function getRootConfigPath() {
966
+ return ROOT_CONFIG_PATH;
967
+ }
968
+
969
+ /**
970
+ * Load .nsaproject.json — returns empty structure if not found.
971
+ *
972
+ * Shape:
973
+ * {
974
+ * environments: { dev: { account, projectId, ddlHandler: { scriptId, deploymentId, ddlAdminKey } } },
975
+ * projects: { "my-db": { kind: "db" } },
976
+ * settings: { loggingVerbosity: 2 }
977
+ * }
978
+ */
979
+ export function loadRootConfig() {
980
+ if (!fs.existsSync(ROOT_CONFIG_PATH)) {
981
+ return { environments: {}, projects: {}, settings: {} };
982
+ }
983
+ return JSON.parse(fs.readFileSync(ROOT_CONFIG_PATH, 'utf8'));
984
+ }
985
+
986
+ /**
987
+ * Save .nsaproject.json.
988
+ */
989
+ export function saveRootConfig(rootConfig) {
990
+ fs.writeFileSync(ROOT_CONFIG_PATH, JSON.stringify(rootConfig, null, 2) + '\n');
991
+ }
992
+
993
+ /**
994
+ * Get the root env config for a given environment.
995
+ * Returns { account, projectId, ddlHandler } or empty object.
996
+ */
997
+ export function getRootEnvConfig(env) {
998
+ const rootConfig = loadRootConfig();
999
+ return rootConfig.environments?.[env] || {};
1000
+ }
1001
+
1002
+ /**
1003
+ * Get root settings (loggingVerbosity, etc.).
1004
+ * DB project settings override these.
1005
+ */
1006
+ export function getRootSettings() {
1007
+ const rootConfig = loadRootConfig();
1008
+ return rootConfig.settings || {};
1009
+ }
1010
+
1011
+ // ── DDL Handler ─────────────────────────
1012
+
1013
+ /**
1014
+ * Get the dist directory for the DDL handler.
1015
+ */
1016
+ export function getDdlHandlerDistDir() {
1017
+ return path.join(DIST_DIR, DDL_HANDLER_DIST_NAME);
1018
+ }
1019
+