nsa-sheets-db-builder 0.0.1-alpha.2 → 4.0.0-alpha.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.
@@ -74,7 +74,7 @@ const command = process.argv[2];
74
74
  const restArgs = process.argv.slice(3);
75
75
 
76
76
  if (!command || command === '--help' || command === '-h') {
77
- console.log('nsa-sheets-db-builder v0.0.1-alpha.2 — Google Sheets database provisioner\n');
77
+ console.log('nsa-sheets-db-builder v4.0.0-alpha.0 — Google Sheets database provisioner\n');
78
78
  console.log('Usage: nsa-sheets-db-builder <command> [options]\n');
79
79
  console.log('Commands:');
80
80
  const maxLen = Math.max(...Object.keys(COMMANDS).map(k => k.length));
@@ -103,9 +103,8 @@ if (!command || command === '--help' || command === '-h') {
103
103
  console.log(' dbs/<name>/rbac.json RBAC config (if enabled)');
104
104
  console.log(' dbs/<name>/views.json SQL view definitions (if enabled)');
105
105
  console.log('\nExamples:');
106
- console.log(' nsa-sheets-db-builder init --db my-app --template blog-cms --user me@gmail.com');
106
+ console.log(' nsa-sheets-db-builder init --db my-app --template blog-cms');
107
107
  console.log(' nsa-sheets-db-builder init --db my-app --instances r,w');
108
- console.log(' nsa-sheets-db-builder init --db my-app --env prod --user prod@gmail.com');
109
108
  console.log(' nsa-sheets-db-builder init --db my-app --rbac request --views');
110
109
  console.log(' nsa-sheets-db-builder build --db my-app');
111
110
  console.log(' nsa-sheets-db-builder push --db my-app --instance rw');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsa-sheets-db-builder",
3
- "version": "0.0.1-alpha.2",
3
+ "version": "4.0.0-alpha.0",
4
4
  "description": "DDL provisioner for Google Sheets — creates spreadsheets, sheets, and headers",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,8 +64,10 @@
64
64
  "ddl-handler": "node scripts/ddl-handler.mjs"
65
65
  },
66
66
  "dependencies": {
67
- "@google/clasp": "^3.2.0",
68
67
  "ejs": "^3.1.10",
69
68
  "esbuild": "^0.27.3"
69
+ },
70
+ "peerDependencies": {
71
+ "@google/clasp": ">=3.0.0"
70
72
  }
71
73
  }
package/scripts/build.mjs CHANGED
@@ -32,8 +32,7 @@ import {
32
32
  getAllInstances, requireInstance, loadTables,
33
33
  loadRbacConfig, loadViewsConfig, loadCustomMethodsConfig, getFeatures, validateRbacRequirements,
34
34
  getDdlHandlerDistDir, getRootEnvConfig,
35
- PACKAGE_ROOT, COMMON_LIBS_DIR, DBS_DIR, ensureAuthenticated,
36
- recordDeployment
35
+ PACKAGE_ROOT, COMMON_LIBS_DIR, DBS_DIR, ensureAuthenticated
37
36
  } from './lib/utils.mjs';
38
37
 
39
38
  // ────────────────────────────────────────
@@ -498,7 +497,6 @@ async function build() {
498
497
  execSync(`npx --prefer-offline @google/clasp deploy --description '${desc}'${claspUserFlag(userProfile)}`, { cwd: distDir, stdio: 'inherit' });
499
498
  console.log('New deployment created.');
500
499
  }
501
- recordDeployment(dbName, env);
502
500
  } catch {
503
501
  console.error('clasp deploy failed.');
504
502
  process.exit(1);
@@ -12,7 +12,7 @@
12
12
  * - Push it to Apps Script
13
13
  * - Deploy it as a web app
14
14
  *
15
- * The DDL handler reference is stored in .env.<env> (root config)
15
+ * The DDL handler reference is stored in .nsaproject.json (root config)
16
16
  * under environments.<env>.ddlHandler.
17
17
  *
18
18
  * Usage:
@@ -41,8 +41,8 @@ import { execSync } from 'child_process';
41
41
  import {
42
42
  parseArgs, requireDb, loadDbConfig, getEnvConfig,
43
43
  ensureAuthenticated, resolveAccount,
44
- getDdlHandlerDistDir, recordDdlHandlerDeployment,
45
- getRootEnvConfig, saveEnvVars
44
+ loadRootConfig, saveRootConfig,
45
+ getDdlHandlerDistDir
46
46
  } from './lib/utils.mjs';
47
47
  import { buildDdlHandler } from './build.mjs';
48
48
 
@@ -76,8 +76,8 @@ async function main() {
76
76
 
77
77
  const account = resolveAccount(envConfig, env);
78
78
  if (!account) {
79
- console.error('ERROR: CLASP_USER not set.');
80
- console.error(`Set CLASP_USER in .env.${env}`);
79
+ console.error('ERROR: account not set.');
80
+ console.error(`Set "account" in .nsaproject.json → environments.${env}`);
81
81
  process.exit(1);
82
82
  }
83
83
 
@@ -91,9 +91,10 @@ async function main() {
91
91
  const doPush = args.push || (!args.build && !args.push && !args.deploy);
92
92
  const doDeploy = args.deploy || (!args.build && !args.push && !args.deploy);
93
93
 
94
- // Load existing handler state from .env.<env>
95
- const rootEnv = getRootEnvConfig(env);
96
- const handler = rootEnv.ddlHandler || {};
94
+ // Load existing handler state from root config
95
+ let rootConfig = loadRootConfig();
96
+ let rootEnv = rootConfig.environments?.[env] || {};
97
+ let handler = rootEnv.ddlHandler || {};
97
98
 
98
99
  const ddlAdminKey = handler.ddlAdminKey || generateAdminKey();
99
100
  let scriptId = handler.scriptId || '';
@@ -195,14 +196,17 @@ async function main() {
195
196
  }
196
197
  }
197
198
 
198
- // ── Save state to .env.<env> ──
199
+ // ── Save state to .nsaproject.json ──
199
200
 
200
- saveEnvVars(env, {
201
- DDL_HANDLER_SCRIPT_ID: scriptId,
202
- DDL_HANDLER_DEPLOYMENT_ID: deploymentId,
203
- DDL_HANDLER_ADMIN_KEY: ddlAdminKey
204
- });
205
- recordDdlHandlerDeployment(env);
201
+ rootConfig = loadRootConfig();
202
+ if (!rootConfig.environments) rootConfig.environments = {};
203
+ if (!rootConfig.environments[env]) rootConfig.environments[env] = {};
204
+ rootConfig.environments[env].ddlHandler = {
205
+ scriptId,
206
+ deploymentId,
207
+ ddlAdminKey
208
+ };
209
+ saveRootConfig(rootConfig);
206
210
 
207
211
  // ── Summary ──
208
212
 
@@ -213,7 +217,7 @@ async function main() {
213
217
  console.log(` Web app: https://script.google.com/macros/s/${deploymentId}/exec`);
214
218
  }
215
219
  console.log(` Admin key: ${ddlAdminKey.substring(0, 8)}...`);
216
- console.log(` Saved to: .env.<env>`);
220
+ console.log(` Saved to: .nsaproject.json`);
217
221
 
218
222
  if (doDeploy && !handler.deploymentId) {
219
223
  console.log(`\n NOTE: Authorize the DDL handler in the Apps Script editor:`);
package/scripts/init.mjs CHANGED
@@ -9,7 +9,6 @@
9
9
  * node scripts/init.mjs --db <name> --instances r,w
10
10
  * node scripts/init.mjs --db <name> --auth simple --rbac
11
11
  * node scripts/init.mjs --db <name> --views
12
- * node scripts/init.mjs --db <name> --env dev --user me@gmail.com
13
12
  *
14
13
  * Interactive: prompts for template selection if --template not given.
15
14
  * Non-interactive: use --template to skip the prompt.
@@ -25,8 +24,6 @@
25
24
  * --rbac Enable role-based access control. Scaffolds rbac.json + users/roles tables.
26
25
  * Requires --auth simple or --auth google.
27
26
  * --views Enable SQL views support. Scaffolds views.json.
28
- * --env Target environment to set --user for (default: dev)
29
- * --user Expected Google account email for the environment
30
27
  *
31
28
  * Creates:
32
29
  * dbs/<name>/project.json — all config (name, settings, authMode, features, environments)
@@ -43,8 +40,7 @@ import readline from 'readline';
43
40
  import {
44
41
  parseArgs, PACKAGE_ROOT, DBS_DIR, COMMON_LIBS_DIR,
45
42
  getEnvDefaults, resolveInstanceType,
46
- loadRootConfig, saveRootConfig, getStateLockPath,
47
- createEnvFile, saveEnvVar
43
+ loadRootConfig, saveRootConfig
48
44
  } from './lib/utils.mjs';
49
45
 
50
46
  const TEMPLATES_DIR = path.join(PACKAGE_ROOT, 'templates');
@@ -119,24 +115,12 @@ function resolveInstances(args) {
119
115
  if (args.instances && typeof args.instances === 'string') {
120
116
  const inputs = args.instances.split(',').map(s => s.trim()).filter(Boolean);
121
117
  const types = inputs.map(resolveInstanceType);
122
-
123
- if (types.length === 1) {
124
- instances['YOUR_SCRIPT_ID'] = { type: types[0], deploymentId: '' };
125
- } else {
126
- // Count occurrences of each type to decide numbering
127
- const typeCounts = {};
128
- for (const t of types) typeCounts[t] = (typeCounts[t] || 0) + 1;
129
-
130
- const typeIdx = {};
131
- for (const type of types) {
132
- const tag = type.toUpperCase().replace(/-/g, '_');
133
- if (typeCounts[type] > 1) {
134
- typeIdx[type] = (typeIdx[type] || 0) + 1;
135
- instances[`YOUR_${tag}_${typeIdx[type]}_SCRIPT_ID`] = { type, deploymentId: '' };
136
- } else {
137
- instances[`YOUR_${tag}_SCRIPT_ID`] = { type, deploymentId: '' };
138
- }
139
- }
118
+ for (const type of types) {
119
+ const placeholder = types.length === 1 ? 'YOUR_SCRIPT_ID' : `YOUR_${type.toUpperCase().replace(/-/g, '_')}_SCRIPT_ID`;
120
+ instances[placeholder] = {
121
+ type,
122
+ deploymentId: ''
123
+ };
140
124
  }
141
125
  } else {
142
126
  instances['YOUR_SCRIPT_ID'] = {
@@ -316,7 +300,7 @@ async function main() {
316
300
  const args = parseArgs();
317
301
 
318
302
  if (!args.db) {
319
- console.error('Usage: node scripts/init.mjs --db <name> [--template <template>] [--instances rw,r,w] [--auth noAuth|simple|google] [--rbac] [--views] [--env <env>] [--user <email>]');
303
+ console.error('Usage: node scripts/init.mjs --db <name> [--template <template>] [--instances rw,r,w] [--auth noAuth|simple|google] [--rbac] [--views]');
320
304
  process.exit(1);
321
305
  }
322
306
 
@@ -403,9 +387,6 @@ async function main() {
403
387
  fs.writeFileSync(path.join(dbDir, 'project.json'), generateProjectJson(dbName, instances, envDefaults, features, authMode));
404
388
  fs.writeFileSync(path.join(dbDir, 'tables.json'), generateTablesJson(tables));
405
389
 
406
- // Create empty state lock file
407
- fs.writeFileSync(getStateLockPath(dbName), JSON.stringify({ schemaVersion: 1, environments: {} }, null, 2) + '\n');
408
-
409
390
  // RBAC config
410
391
  if (features.rbac) {
411
392
  fs.writeFileSync(path.join(dbDir, 'rbac.json'), generateRbacJson());
@@ -436,26 +417,32 @@ async function main() {
436
417
  // Register project in .nsaproject.json
437
418
  const rootConfig = loadRootConfig();
438
419
  if (!rootConfig.kind) rootConfig.kind = 'db';
439
- if (!Array.isArray(rootConfig.projects)) rootConfig.projects = [];
420
+ if (!rootConfig.environments) rootConfig.environments = {};
421
+ if (!rootConfig.projects) rootConfig.projects = [];
422
+
423
+ // Ensure env entries exist
424
+ for (const envName of ['dev', 'prod']) {
425
+ if (!rootConfig.environments[envName]) {
426
+ rootConfig.environments[envName] = {
427
+ account: '',
428
+ projectId: '',
429
+ ddlHandler: { scriptId: '', deploymentId: '', ddlAdminKey: '' }
430
+ };
431
+ }
432
+ }
440
433
 
434
+ // Store account from env defaults if available and not already set
435
+ if (envDefaults.account && !rootConfig.environments.dev.account) {
436
+ rootConfig.environments.dev.account = envDefaults.account;
437
+ }
438
+
439
+ // Add project to registry if not already listed
441
440
  if (!rootConfig.projects.includes(dbName)) {
442
441
  rootConfig.projects.push(dbName);
443
442
  }
444
443
 
445
444
  saveRootConfig(rootConfig);
446
445
 
447
- // Create .env.<env> files if they don't exist
448
- const targetEnv = args.env || 'dev';
449
- const userAccount = args.user || envDefaults.account || '';
450
-
451
- createEnvFile('dev', { account: targetEnv === 'dev' ? userAccount : '' });
452
- createEnvFile('prod', { account: targetEnv === 'prod' ? userAccount : '' });
453
-
454
- // If --user provided for a specific env, update that env file
455
- if (userAccount) {
456
- saveEnvVar(targetEnv, 'CLASP_USER', userAccount);
457
- }
458
-
459
446
  const tableNames = Object.keys(tables);
460
447
  const instanceKeys = Object.keys(instances);
461
448
  console.log(`\nCreated DB project: ${dbName}`);
@@ -46,7 +46,6 @@ export const DIST_DIR = path.join(PROJECT_ROOT, 'dist');
46
46
 
47
47
  // Root project config — shared across all projects under this installation
48
48
  const ROOT_CONFIG_PATH = path.join(PROJECT_ROOT, '.nsaproject.json');
49
- const ROOT_LOCK_PATH = path.join(PROJECT_ROOT, '.nsaproject.lock.json');
50
49
 
51
50
  // DDL Handler dist directory
52
51
  const DDL_HANDLER_DIST_NAME = '__ddl-handler__';
@@ -95,23 +94,18 @@ export function requireDb(args, scriptName) {
95
94
  // ── Config I/O ───────────────────────────
96
95
  //
97
96
  // Project layout:
98
- // dbs/<name>/project.json config (name, settings, authMode, instances, features)
99
- // dbs/<name>/state.lock.json deployment state (auto-generated, do not edit)
100
- // dbs/<name>/tables.json table definitions (schema)
101
- // dbs/<name>/rbac.json RBAC capabilities + privacy filters (if enabled)
102
- // dbs/<name>/views.json SQL view definitions (if enabled)
103
- // dbs/<name>/customMethods.json custom method declarations
104
- // dbs/<name>/libs/ scaffolded library source files
105
- // dbs/<name>/overrides/ — custom method handler files
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
106
104
 
107
105
  export function getProjectConfigPath(dbName) {
108
106
  return path.join(DBS_DIR, dbName, 'project.json');
109
107
  }
110
108
 
111
- export function getStateLockPath(dbName) {
112
- return path.join(DBS_DIR, dbName, 'state.lock.json');
113
- }
114
-
115
109
  export function getTablesPath(dbName) {
116
110
  return path.join(DBS_DIR, dbName, 'tables.json');
117
111
  }
@@ -157,116 +151,13 @@ export function loadDbConfig(dbName) {
157
151
  }
158
152
 
159
153
  /**
160
- * Save project.json (config only: name, settings, authMode, instances, features).
154
+ * Save project.json (all config: name, settings, features, environments).
161
155
  */
162
156
  export function saveProjectConfig(dbName, config) {
163
157
  const p = getProjectConfigPath(dbName);
164
158
  fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n');
165
159
  }
166
160
 
167
- // ── State Lock I/O (per-DB) ─────────────
168
- //
169
- // state.lock.json — auto-generated metadata, do not edit manually.
170
- // Tracks schema hashes, deployment timestamps, and migration history.
171
- // The source of truth for "what's deployed" remains project.json + tables.json.
172
- //
173
- // Shape:
174
- // {
175
- // schemaVersion: 1,
176
- // environments: {
177
- // dev: {
178
- // lastDeployed: "2026-02-11T16:44:00Z",
179
- // tables: {
180
- // "users": { schemaHash: "a3f2c1...", lastProvisioned: "2026-02-11T..." }
181
- // },
182
- // migrations: [
183
- // { id: 1, applied: "2026-02-11T...", description: "Initial schema" }
184
- // ]
185
- // }
186
- // }
187
- // }
188
-
189
- /**
190
- * Load state.lock.json — returns empty structure if not found.
191
- */
192
- export function loadStateLock(dbName) {
193
- const p = getStateLockPath(dbName);
194
- if (!fs.existsSync(p)) {
195
- return { schemaVersion: 1, environments: {} };
196
- }
197
- return JSON.parse(fs.readFileSync(p, 'utf8'));
198
- }
199
-
200
- /**
201
- * Save state.lock.json.
202
- */
203
- export function saveStateLock(dbName, state) {
204
- const p = getStateLockPath(dbName);
205
- state.lastUpdated = new Date().toISOString();
206
- fs.writeFileSync(p, JSON.stringify(state, null, 2) + '\n');
207
- }
208
-
209
- /**
210
- * Get environment state from state.lock.json.
211
- */
212
- export function getEnvState(state, envName) {
213
- const env = envName || 'dev';
214
- return state.environments?.[env] || {};
215
- }
216
-
217
- /**
218
- * Ensure an environment entry exists in state lock and return it (mutable reference).
219
- */
220
- export function ensureEnvState(state, envName) {
221
- const env = envName || 'dev';
222
- if (!state.environments) state.environments = {};
223
- if (!state.environments[env]) {
224
- state.environments[env] = {
225
- lastDeployed: null,
226
- tables: {},
227
- migrations: []
228
- };
229
- }
230
- return state.environments[env];
231
- }
232
-
233
- /**
234
- * Compute a simple hash of a table schema for drift detection.
235
- */
236
- export function computeSchemaHash(tableDefinition) {
237
- const str = JSON.stringify(tableDefinition.schema || {});
238
- let hash = 0;
239
- for (let i = 0; i < str.length; i++) {
240
- const chr = str.charCodeAt(i);
241
- hash = ((hash << 5) - hash) + chr;
242
- hash |= 0;
243
- }
244
- return Math.abs(hash).toString(36);
245
- }
246
-
247
- /**
248
- * Record table provisioning state in the lock file.
249
- */
250
- export function recordTableState(dbName, env, tableName, tableDefinition) {
251
- const state = loadStateLock(dbName);
252
- const envState = ensureEnvState(state, env);
253
- envState.tables[tableName] = {
254
- schemaHash: computeSchemaHash(tableDefinition),
255
- lastProvisioned: new Date().toISOString()
256
- };
257
- saveStateLock(dbName, state);
258
- }
259
-
260
- /**
261
- * Record a deployment in the lock file.
262
- */
263
- export function recordDeployment(dbName, env) {
264
- const state = loadStateLock(dbName);
265
- const envState = ensureEnvState(state, env);
266
- envState.lastDeployed = new Date().toISOString();
267
- saveStateLock(dbName, state);
268
- }
269
-
270
161
  /**
271
162
  * Load tables.json — exits if not found.
272
163
  */
@@ -370,7 +261,7 @@ export function validateRbacRequirements(tables, rbacConfig) {
370
261
 
371
262
  /**
372
263
  * Resolve environment config — uses --env flag or defaults to 'dev'.
373
- * Returns { env, envConfig } where envConfig has instances, spreadsheetId, etc.
264
+ * Returns { env, envConfig } where envConfig has instances, account, spreadsheetId.
374
265
  */
375
266
  export function getEnvConfig(config, envOverride) {
376
267
  const env = envOverride || 'dev';
@@ -431,10 +322,8 @@ export function getDistDirForInstance(dbName, instanceType) {
431
322
 
432
323
  // ── Instance Helpers ─────────────────────
433
324
  //
434
- // State lock shape: instances keyed by scriptId, value has type + deploymentId + ddlAdminKey.
435
- // "instances": { "SCRIPT_ID": { "type": "read-write", "deploymentId": "", "ddlAdminKey": "" } }
436
- //
437
- // Config shape: instances is an array of type strings: ["read-write", "read-only"]
325
+ // Config shape: instances keyed by scriptId, value has type + deploymentId.
326
+ // "instances": { "SCRIPT_ID": { "type": "read-write", "deploymentId": "" } }
438
327
  //
439
328
  // --instance flag accepts a short alias (rw, r, w), full type, or scriptId.
440
329
 
@@ -462,7 +351,7 @@ export function resolveInstanceType(input) {
462
351
  * Get all instances for an environment config.
463
352
  * Each entry: { scriptId, type, deploymentId }
464
353
  *
465
- * @param {object} envConfig - Environment config from project.json
354
+ * @param {object} envConfig - Environment config
466
355
  * @returns {Array<{ scriptId: string, type: string, deploymentId: string }>}
467
356
  */
468
357
  export function getAllInstances(envConfig) {
@@ -477,8 +366,8 @@ export function getAllInstances(envConfig) {
477
366
  /**
478
367
  * Resolve a single instance by type or scriptId.
479
368
  *
480
- * @param {object} envConfig - Environment config from project.json
481
- * @param {string} identifier - Instance type (e.g. "read-write") or scriptId
369
+ * @param {object} envConfig - Environment config
370
+ * @param {string} identifier - Instance type (e.g. "reader-writer") or scriptId
482
371
  * @returns {{ scriptId: string, type: string, deploymentId: string }}
483
372
  */
484
373
  export function getInstanceConfig(envConfig, identifier) {
@@ -511,7 +400,7 @@ export function getInstanceConfig(envConfig, identifier) {
511
400
  * Require an --instance flag or auto-select if only one instance exists.
512
401
  *
513
402
  * @param {object} args - Parsed CLI args
514
- * @param {object} envConfig - Environment config from project.json
403
+ * @param {object} envConfig - Environment config
515
404
  * @returns {{ scriptId: string, type: string, deploymentId: string }}
516
405
  */
517
406
  export function requireInstance(args, envConfig) {
@@ -812,13 +701,14 @@ export async function getAccountEmail(profile) {
812
701
 
813
702
  /**
814
703
  * Resolve the account for an environment.
815
- * Reads from .env.<env> file account is env-level.
704
+ * Checks DB envConfig first, then root config (.nsaproject.json).
816
705
  *
817
- * @param {object} envConfig - DB environment config (unused, kept for compat)
706
+ * @param {object} envConfig - DB environment config
818
707
  * @param {string} env - Environment name
819
708
  * @returns {string} Account email or empty string
820
709
  */
821
710
  export function resolveAccount(envConfig, env) {
711
+ if (envConfig.account) return envConfig.account;
822
712
  const rootEnv = getRootEnvConfig(env);
823
713
  return rootEnv.account || '';
824
714
  }
@@ -882,7 +772,7 @@ export async function ensureAuthenticated(dbName, args, envConfig, env) {
882
772
  profile = fallback;
883
773
  } else {
884
774
  console.error(`\nNo clasp credentials found.`);
885
- console.error(`Run: nsa-sheets-db-builder login --db ${dbName} --env ${env}`);
775
+ console.error(`Run: npm run login -- --db ${dbName} --env ${env}`);
886
776
  process.exit(1);
887
777
  }
888
778
  }
@@ -894,18 +784,22 @@ export async function ensureAuthenticated(dbName, args, envConfig, env) {
894
784
  console.error(`\nAuth error: ${validation.reason}`);
895
785
  if (validation.reason?.includes('mismatch')) {
896
786
  console.error(`Logged-in account does not match the declared account for this environment.`);
897
- console.error(`Run: nsa-sheets-db-builder login --db ${dbName} --env ${env}`);
787
+ console.error(`Run: npm run login -- --db ${dbName} --env ${env}`);
898
788
  } else if (validation.reason?.includes('expired')) {
899
- console.error(`Run: nsa-sheets-db-builder login --db ${dbName} --env ${env}`);
789
+ console.error(`Run: npm run login -- --db ${dbName} --env ${env}`);
900
790
  }
901
791
  process.exit(1);
902
792
  }
903
793
 
904
- // Store email in env file if account field was empty
794
+ // Store email in root config if account field was empty
905
795
  const knownAccount = resolveAccount(envConfig, env);
906
796
  if (!knownAccount && validation.email) {
907
- saveEnvVar(env, 'CLASP_USER', validation.email);
908
- console.log(` Stored account "${validation.email}" in .env.${env}`);
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`);
909
803
  }
910
804
 
911
805
  console.log(` Using account: ${validation.email} (profile: ${profile})`);
@@ -1004,25 +898,7 @@ export function isPlaceholderScriptId(scriptId) {
1004
898
  return !scriptId || scriptId.startsWith('YOUR_');
1005
899
  }
1006
900
 
1007
- // ── .env.<env> File Support ──────────────
1008
- //
1009
- // Environment-level config lives in .env.<env> files (mandatory).
1010
- // These are the single source of truth for account, projectId, DDL handler, etc.
1011
- //
1012
- // .env.dev:
1013
- // CLASP_USER=me@gmail.com
1014
- // GCP_PROJECT_ID=354563759750
1015
- // DDL_HANDLER_SCRIPT_ID=1abc...
1016
- // DDL_HANDLER_DEPLOYMENT_ID=AKfyc...
1017
- // DDL_HANDLER_ADMIN_KEY=a1b2c3...
1018
- // DRIVE_ROOT_FOLDER_ID= (optional)
1019
-
1020
- /**
1021
- * Get the path to an env file.
1022
- */
1023
- export function getEnvFilePath(env) {
1024
- return path.join(PROJECT_ROOT, `.env.${env}`);
1025
- }
901
+ // ── .env File Support ────────────────────
1026
902
 
1027
903
  /**
1028
904
  * Load a .env.<env> file from the project root.
@@ -1032,7 +908,7 @@ export function getEnvFilePath(env) {
1032
908
  * @returns {object} Key-value pairs, empty object if file missing
1033
909
  */
1034
910
  export function loadEnvFile(env) {
1035
- const envPath = getEnvFilePath(env);
911
+ const envPath = path.join(PROJECT_ROOT, `.env.${env}`);
1036
912
  if (!fs.existsSync(envPath)) {
1037
913
  return {};
1038
914
  }
@@ -1063,145 +939,25 @@ export function loadEnvFile(env) {
1063
939
  }
1064
940
 
1065
941
  /**
1066
- * Save/update a key-value pair in .env.<env> file.
1067
- * Creates the file if it doesn't exist. Updates existing key in place.
1068
- *
1069
- * @param {string} env - Environment name
1070
- * @param {string} key - Variable name
1071
- * @param {string} value - Variable value
1072
- */
1073
- export function saveEnvVar(env, key, value) {
1074
- const envPath = getEnvFilePath(env);
1075
- let lines = [];
1076
-
1077
- if (fs.existsSync(envPath)) {
1078
- lines = fs.readFileSync(envPath, 'utf8').split('\n');
1079
- }
1080
-
1081
- // Find existing key and update, or append
1082
- let found = false;
1083
- for (let i = 0; i < lines.length; i++) {
1084
- const trimmed = lines[i].trim();
1085
- if (trimmed.startsWith('#') || !trimmed) continue;
1086
- const eqIdx = trimmed.indexOf('=');
1087
- if (eqIdx === -1) continue;
1088
- if (trimmed.slice(0, eqIdx).trim() === key) {
1089
- lines[i] = `${key}=${value}`;
1090
- found = true;
1091
- break;
1092
- }
1093
- }
1094
-
1095
- if (!found) {
1096
- // Ensure trailing newline before appending
1097
- if (lines.length > 0 && lines[lines.length - 1] !== '') {
1098
- lines.push('');
1099
- }
1100
- lines.push(`${key}=${value}`);
1101
- }
1102
-
1103
- // Ensure file ends with newline
1104
- const content = lines.join('\n').replace(/\n*$/, '\n');
1105
- fs.writeFileSync(envPath, content);
1106
- }
1107
-
1108
- /**
1109
- * Save multiple key-value pairs to .env.<env> file.
1110
- */
1111
- export function saveEnvVars(env, vars) {
1112
- for (const [key, value] of Object.entries(vars)) {
1113
- saveEnvVar(env, key, value);
1114
- }
1115
- }
1116
-
1117
- /**
1118
- * Create a .env.<env> file with initial values.
1119
- * Only creates if file doesn't already exist.
942
+ * Get environment defaults from .env file.
943
+ * These serve as fallbacks for project/api config values.
1120
944
  *
1121
945
  * @param {string} env - Environment name
1122
- * @param {object} vars - Initial key-value pairs
1123
- */
1124
- export function createEnvFile(env, vars = {}) {
1125
- const envPath = getEnvFilePath(env);
1126
- if (fs.existsSync(envPath)) return;
1127
-
1128
- const lines = [
1129
- `# .env.${env} — environment config for nsa-sheets-db-builder`,
1130
- `# Auto-generated. Edit values as needed.`,
1131
- '',
1132
- '# Google account used for clasp login',
1133
- `CLASP_USER=${vars.account || ''}`,
1134
- '',
1135
- '# Google Cloud project ID (for GAS deployment)',
1136
- `GCP_PROJECT_ID=${vars.projectId || ''}`,
1137
- '',
1138
- '# DDL Handler (shared per account — auto-populated by setup)',
1139
- `DDL_HANDLER_SCRIPT_ID=${vars.ddlHandlerScriptId || ''}`,
1140
- `DDL_HANDLER_DEPLOYMENT_ID=${vars.ddlHandlerDeploymentId || ''}`,
1141
- `DDL_HANDLER_ADMIN_KEY=${vars.ddlHandlerAdminKey || ''}`,
1142
- '',
1143
- '# Optional',
1144
- `DRIVE_ROOT_FOLDER_ID=${vars.driveFolderId || ''}`,
1145
- ''
1146
- ];
1147
-
1148
- fs.writeFileSync(envPath, lines.join('\n'));
1149
- }
1150
-
1151
- /**
1152
- * Require an env file to exist, or exit with error.
1153
- */
1154
- export function requireEnvFile(env) {
1155
- const envPath = getEnvFilePath(env);
1156
- if (!fs.existsSync(envPath)) {
1157
- console.error(`Missing .env.${env} file at ${envPath}`);
1158
- console.error(`Run "nsa-sheets-db-builder init" to create one, or create it manually.`);
1159
- process.exit(1);
1160
- }
1161
- }
1162
-
1163
- /**
1164
- * Get environment config from .env.<env> file.
1165
- * This is the primary source for env-level vars.
1166
- *
1167
- * @param {string} env - Environment name
1168
- * @returns {{ account, projectId, ddlHandler: { scriptId, deploymentId, ddlAdminKey }, driveFolderId }}
1169
- */
1170
- export function getRootEnvConfig(env) {
1171
- const vars = loadEnvFile(env);
1172
- return {
1173
- account: vars.CLASP_USER || '',
1174
- projectId: vars.GCP_PROJECT_ID || '',
1175
- ddlHandler: {
1176
- scriptId: vars.DDL_HANDLER_SCRIPT_ID || '',
1177
- deploymentId: vars.DDL_HANDLER_DEPLOYMENT_ID || '',
1178
- ddlAdminKey: vars.DDL_HANDLER_ADMIN_KEY || ''
1179
- },
1180
- driveFolderId: vars.DRIVE_ROOT_FOLDER_ID || ''
1181
- };
1182
- }
1183
-
1184
- /**
1185
- * Get environment defaults from .env file (alias for backward compat).
946
+ * @returns {{ account?: string, driveFolderId?: string, loggingVerbosity?: number }}
1186
947
  */
1187
948
  export function getEnvDefaults(env) {
1188
- const vars = loadEnvFile(env);
949
+ const envVars = loadEnvFile(env);
1189
950
  const defaults = {};
1190
951
 
1191
- if (vars.CLASP_USER) defaults.account = vars.CLASP_USER;
1192
- if (vars.DRIVE_ROOT_FOLDER_ID) defaults.driveFolderId = vars.DRIVE_ROOT_FOLDER_ID;
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;
1193
956
 
1194
957
  return defaults;
1195
958
  }
1196
959
 
1197
960
  // ── Root Config (.nsaproject.json) ───────
1198
- //
1199
- // Project registry only — no environment data (that's in .env.<env> files).
1200
- // Shape:
1201
- // {
1202
- // kind: "db",
1203
- // projects: ["my-blog", "my-crm"]
1204
- // }
1205
961
 
1206
962
  /**
1207
963
  * Path to the root project config file.
@@ -1212,10 +968,17 @@ export function getRootConfigPath() {
1212
968
 
1213
969
  /**
1214
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
+ * }
1215
978
  */
1216
979
  export function loadRootConfig() {
1217
980
  if (!fs.existsSync(ROOT_CONFIG_PATH)) {
1218
- return { projects: [] };
981
+ return { environments: {}, projects: {}, settings: {} };
1219
982
  }
1220
983
  return JSON.parse(fs.readFileSync(ROOT_CONFIG_PATH, 'utf8'));
1221
984
  }
@@ -1228,73 +991,21 @@ export function saveRootConfig(rootConfig) {
1228
991
  }
1229
992
 
1230
993
  /**
1231
- * Get root settings (loggingVerbosity, etc.).
1232
- * DB project settings override these.
994
+ * Get the root env config for a given environment.
995
+ * Returns { account, projectId, ddlHandler } or empty object.
1233
996
  */
1234
- export function getRootSettings() {
997
+ export function getRootEnvConfig(env) {
1235
998
  const rootConfig = loadRootConfig();
1236
- return rootConfig.settings || {};
1237
- }
1238
-
1239
- // ── Root Lock (.nsaproject.lock.json) ────
1240
- //
1241
- // Auto-generated metadata — do not edit manually.
1242
- // Tracks deployment timestamps and DDL handler usage state.
1243
- // The source of truth for DDL handler config stays in .nsaproject.json.
1244
- //
1245
- // Shape:
1246
- // {
1247
- // environments: {
1248
- // dev: {
1249
- // ddlHandler: { lastDeployed: "2026-02-11T...", lastUsed: "2026-02-11T..." }
1250
- // }
1251
- // }
1252
- // }
1253
-
1254
- export function getRootLockPath() {
1255
- return ROOT_LOCK_PATH;
1256
- }
1257
-
1258
- /**
1259
- * Load .nsaproject.lock.json — returns empty structure if not found.
1260
- */
1261
- export function loadRootLock() {
1262
- if (!fs.existsSync(ROOT_LOCK_PATH)) {
1263
- return { environments: {} };
1264
- }
1265
- return JSON.parse(fs.readFileSync(ROOT_LOCK_PATH, 'utf8'));
1266
- }
1267
-
1268
- /**
1269
- * Save .nsaproject.lock.json.
1270
- */
1271
- export function saveRootLock(rootLock) {
1272
- rootLock.lastUpdated = new Date().toISOString();
1273
- fs.writeFileSync(ROOT_LOCK_PATH, JSON.stringify(rootLock, null, 2) + '\n');
999
+ return rootConfig.environments?.[env] || {};
1274
1000
  }
1275
1001
 
1276
1002
  /**
1277
- * Record DDL handler deployment in root lock.
1278
- */
1279
- export function recordDdlHandlerDeployment(env) {
1280
- const rootLock = loadRootLock();
1281
- if (!rootLock.environments) rootLock.environments = {};
1282
- if (!rootLock.environments[env]) rootLock.environments[env] = {};
1283
- if (!rootLock.environments[env].ddlHandler) rootLock.environments[env].ddlHandler = {};
1284
- rootLock.environments[env].ddlHandler.lastDeployed = new Date().toISOString();
1285
- saveRootLock(rootLock);
1286
- }
1287
-
1288
- /**
1289
- * Record DDL handler usage in root lock.
1003
+ * Get root settings (loggingVerbosity, etc.).
1004
+ * DB project settings override these.
1290
1005
  */
1291
- export function recordDdlHandlerUsage(env) {
1292
- const rootLock = loadRootLock();
1293
- if (!rootLock.environments) rootLock.environments = {};
1294
- if (!rootLock.environments[env]) rootLock.environments[env] = {};
1295
- if (!rootLock.environments[env].ddlHandler) rootLock.environments[env].ddlHandler = {};
1296
- rootLock.environments[env].ddlHandler.lastUsed = new Date().toISOString();
1297
- saveRootLock(rootLock);
1006
+ export function getRootSettings() {
1007
+ const rootConfig = loadRootConfig();
1008
+ return rootConfig.settings || {};
1298
1009
  }
1299
1010
 
1300
1011
  // ── DDL Handler ─────────────────────────
package/scripts/login.mjs CHANGED
@@ -7,15 +7,15 @@
7
7
  * node scripts/login.mjs --db <name> [--env <env>] [--account user@gmail.com]
8
8
  *
9
9
  * Flow:
10
- * 1. Ask for the Gmail account (or use --account flag / existing .env.<env>)
10
+ * 1. Ask for the Gmail account (or use --account flag / existing config)
11
11
  * 2. Run `clasp login --user <dbName>--<env>`
12
12
  * 3. Verify the logged-in account matches the specified one
13
- * 4. Store email in .env.<env>CLASP_USER
13
+ * 4. Store email in .nsaproject.jsonenvironments.<env>.account
14
14
  */
15
15
 
16
16
  import readline from 'readline';
17
17
  import { execSync } from 'child_process';
18
- import { parseArgs, requireDb, loadDbConfig, getEnvConfig, getClaspUserProfile, getAccountEmail, resolveAccount, saveEnvVar } from './lib/utils.mjs';
18
+ import { parseArgs, requireDb, loadDbConfig, getEnvConfig, getClaspUserProfile, getAccountEmail, resolveAccount, loadRootConfig, saveRootConfig } from './lib/utils.mjs';
19
19
 
20
20
  function prompt(question) {
21
21
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -72,7 +72,11 @@ const actualEmail = await getAccountEmail(profile);
72
72
  if (!actualEmail) {
73
73
  console.warn('\nWarning: Could not verify account email (token may not have userinfo scope).');
74
74
  console.log('Saving specified account to config anyway.\n');
75
- saveEnvVar(env, 'CLASP_USER', expectedAccount);
75
+ const rc1 = loadRootConfig();
76
+ if (!rc1.environments) rc1.environments = {};
77
+ if (!rc1.environments[env]) rc1.environments[env] = {};
78
+ rc1.environments[env].account = expectedAccount;
79
+ saveRootConfig(rc1);
76
80
  console.log(`Login complete for ${dbName} (${env})`);
77
81
  console.log(` Profile: ${profile}`);
78
82
  console.log(` Account (unverified): ${expectedAccount}`);
@@ -87,8 +91,12 @@ if (actualEmail.toLowerCase() !== expectedAccount.toLowerCase()) {
87
91
  process.exit(1);
88
92
  }
89
93
 
90
- // 4. Store account in .env.<env>
91
- saveEnvVar(env, 'CLASP_USER', actualEmail);
94
+ // 4. Store account in root config
95
+ const rc2 = loadRootConfig();
96
+ if (!rc2.environments) rc2.environments = {};
97
+ if (!rc2.environments[env]) rc2.environments[env] = {};
98
+ rc2.environments[env].account = actualEmail;
99
+ saveRootConfig(rc2);
92
100
 
93
101
  console.log(`\nLogged in as ${actualEmail} for ${dbName} (${env})`);
94
102
  console.log(` Profile: ${profile}`);
@@ -7,7 +7,7 @@
7
7
  * node scripts/provision.mjs --db <name> [--env <env>] [--instance <type>]
8
8
  */
9
9
 
10
- import { parseArgs, requireDb, loadDbConfig, getEnvConfig, getDistDirForInstance, claspRun, loadTables, requireDataSpreadsheet, requireInstance, ensureAuthenticated, recordTableState } from './lib/utils.mjs';
10
+ import { parseArgs, requireDb, loadDbConfig, getEnvConfig, getDistDirForInstance, claspRun, loadTables, requireDataSpreadsheet, requireInstance, ensureAuthenticated } from './lib/utils.mjs';
11
11
 
12
12
  const args = parseArgs();
13
13
  const dbName = requireDb(args, 'provision.mjs');
@@ -33,8 +33,3 @@ const result = claspRun(distDir, 'ddlProvisionTables', [{
33
33
  }], { userProfile });
34
34
 
35
35
  console.log('\nResult:', JSON.stringify(result, null, 2));
36
-
37
- // Record table state in lock file
38
- for (const [tableName, tableDef] of Object.entries(tables)) {
39
- recordTableState(dbName, env, tableName, tableDef);
40
- }
package/scripts/setup.mjs CHANGED
@@ -36,10 +36,8 @@ import {
36
36
  getFeatures, validateRbacRequirements,
37
37
  ensureAuthenticated, isPlaceholderScriptId,
38
38
  getDataSpreadsheetIds,
39
- resolveAccount,
40
- getDdlHandlerDistDir,
41
- recordDdlHandlerDeployment, recordDeployment, recordTableState,
42
- getRootEnvConfig, saveEnvVars
39
+ loadRootConfig, saveRootConfig, resolveAccount,
40
+ getDdlHandlerDistDir
43
41
  } from './lib/utils.mjs';
44
42
  import { buildInstance, buildDdlHandler, getDeploymentDescription } from './build.mjs';
45
43
 
@@ -246,14 +244,15 @@ async function setup() {
246
244
 
247
245
  const account = resolveAccount(envConfig, env);
248
246
  if (!account) {
249
- console.error(' ERROR: CLASP_USER not set.');
250
- console.error(` Set CLASP_USER in .env.${env}`);
247
+ console.error(' ERROR: account not set.');
248
+ console.error(' Set "account" in .nsaproject.json → environments.' + env);
251
249
  console.error(' Or run login first to auto-detect.');
252
250
  process.exit(1);
253
251
  }
254
252
 
255
- // Resolve DDL handler from .env.<env>
256
- const rootEnv = getRootEnvConfig(env);
253
+ // Resolve DDL handler from root config (.nsaproject.json)
254
+ let rootConfig = loadRootConfig();
255
+ let rootEnv = rootConfig.environments?.[env] || {};
257
256
  let ddlHandler = rootEnv.ddlHandler || {};
258
257
 
259
258
  if (ddlHandler.scriptId && ddlHandler.deploymentId) {
@@ -342,16 +341,18 @@ async function setup() {
342
341
  console.log(` deploymentId: ${handlerDeploymentId}`);
343
342
  }
344
343
 
345
- // f. Save to .env.<env>
346
- saveEnvVars(env, {
347
- DDL_HANDLER_SCRIPT_ID: handlerScriptId,
348
- DDL_HANDLER_DEPLOYMENT_ID: handlerDeploymentId,
349
- DDL_HANDLER_ADMIN_KEY: handlerAdminKey
350
- });
351
- recordDdlHandlerDeployment(env);
352
- ddlHandler = { scriptId: handlerScriptId, deploymentId: handlerDeploymentId, ddlAdminKey: handlerAdminKey };
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;
353
354
 
354
- console.log(`\n DDL handler saved to .env.${env}`);
355
+ console.log(`\n DDL handler saved to .nsaproject.json`);
355
356
 
356
357
  // g. Print authorization reminder
357
358
  console.log(`\n IMPORTANT: Authorize the DDL handler in the Apps Script editor:`);
@@ -570,7 +571,6 @@ async function setup() {
570
571
  process.exit(1);
571
572
  }
572
573
  }
573
- recordDeployment(dbName, env);
574
574
  console.log('\n Re-build + re-push + re-deploy complete.');
575
575
  }
576
576
 
@@ -604,11 +604,6 @@ async function setup() {
604
604
  }
605
605
  console.log(' Provision result:', JSON.stringify(provisionResult, null, 2));
606
606
 
607
- // Record table state in lock file
608
- for (const [tableName, tableDef] of Object.entries(tables)) {
609
- recordTableState(dbName, env, tableName, tableDef);
610
- }
611
-
612
607
  // ── Step 10: Rotate API keys (via DB script web app) ──
613
608
 
614
609
  step(10, 'Rotate API keys');