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.
- package/LICENSE +21 -0
- package/README.md +188 -0
- package/bin/sheets-deployer.mjs +169 -0
- package/libs/alasql.js +15577 -0
- package/libs/common/gas_response_helper.ts +147 -0
- package/libs/common/gaserror.ts +101 -0
- package/libs/common/gaslogger.ts +172 -0
- package/libs/db_ddl.ts +316 -0
- package/libs/libraries.json +56 -0
- package/libs/spreadsheets_db.ts +4406 -0
- package/libs/triggers.ts +113 -0
- package/package.json +73 -0
- package/scripts/build.mjs +513 -0
- package/scripts/clean.mjs +31 -0
- package/scripts/create.mjs +94 -0
- package/scripts/ddl-handler.mjs +232 -0
- package/scripts/describe.mjs +38 -0
- package/scripts/drop.mjs +39 -0
- package/scripts/init.mjs +465 -0
- package/scripts/lib/utils.mjs +1019 -0
- package/scripts/login.mjs +102 -0
- package/scripts/provision.mjs +35 -0
- package/scripts/refresh-cache.mjs +34 -0
- package/scripts/set-key.mjs +48 -0
- package/scripts/setup-trigger.mjs +95 -0
- package/scripts/setup.mjs +677 -0
- package/scripts/show.mjs +37 -0
- package/scripts/sync.mjs +35 -0
- package/scripts/whoami.mjs +36 -0
- package/src/api/ddl-handler-entry.ts +136 -0
- package/src/api/ddl.ts +321 -0
- package/src/templates/.clasp.json.ejs +1 -0
- package/src/templates/appsscript.json.ejs +16 -0
- package/src/templates/config.ts.ejs +14 -0
- package/src/templates/ddl-handler-config.ts.ejs +3 -0
- package/src/templates/ddl-handler-main.ts.ejs +56 -0
- package/src/templates/main.ts.ejs +288 -0
- package/src/templates/rbac.ts.ejs +148 -0
- package/src/templates/views.ts.ejs +92 -0
- package/templates/blank.json +33 -0
- package/templates/blog-cms.json +507 -0
- package/templates/crm.json +360 -0
- package/templates/e-commerce.json +424 -0
- 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
|
+
|