@bostonuniversity/buwp-local 0.4.0 → 0.5.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.
@@ -57,9 +57,6 @@ function generateComposeConfig(config) {
57
57
  * @returns {object} Database service config
58
58
  */
59
59
  function generateDbService(config, dbVolumeName) {
60
- const dbPassword = config.db?.password || 'password';
61
- const rootPassword = config.db?.rootPassword || 'rootpassword';
62
-
63
60
  return {
64
61
  image: 'mariadb:latest',
65
62
  restart: 'always',
@@ -67,8 +64,8 @@ function generateDbService(config, dbVolumeName) {
67
64
  environment: {
68
65
  MYSQL_DATABASE: 'wordpress',
69
66
  MYSQL_USER: 'wordpress',
70
- MYSQL_PASSWORD: dbPassword,
71
- MYSQL_ROOT_PASSWORD: rootPassword
67
+ MYSQL_PASSWORD: '${WORDPRESS_DB_PASSWORD:-password}',
68
+ MYSQL_ROOT_PASSWORD: '${DB_ROOT_PASSWORD:-rootpassword}'
72
69
  },
73
70
  ports: [`${config.ports.db}:3306`],
74
71
  networks: ['wp-network']
@@ -91,7 +88,7 @@ function generateWordPressService(config, wpVolumeName) {
91
88
  const environment = {
92
89
  WORDPRESS_DB_HOST: 'db:3306',
93
90
  WORDPRESS_DB_USER: 'wordpress',
94
- WORDPRESS_DB_PASSWORD: config.db?.password || 'password',
91
+ WORDPRESS_DB_PASSWORD: '${WORDPRESS_DB_PASSWORD:-password}',
95
92
  WORDPRESS_DB_NAME: 'wordpress',
96
93
  WORDPRESS_DEBUG: config.env?.WP_DEBUG || '0',
97
94
  SERVER_NAME: config.hostname,
@@ -104,11 +101,11 @@ function generateWordPressService(config, wpVolumeName) {
104
101
 
105
102
  // Add Shibboleth config if enabled
106
103
  if (config.services.shibboleth) {
107
- environment.SP_ENTITY_ID = config.shibboleth?.entityId || '';
108
- environment.IDP_ENTITY_ID = config.shibboleth?.idpEntityId || '';
109
- environment.SHIB_IDP_LOGOUT = config.shibboleth?.idpLogout || '';
110
- environment.SHIB_SP_KEY = config.shibboleth?.spKey || '';
111
- environment.SHIB_SP_CERT = config.shibboleth?.spCert || '';
104
+ environment.SP_ENTITY_ID = '${SP_ENTITY_ID:-}';
105
+ environment.IDP_ENTITY_ID = '${IDP_ENTITY_ID:-}';
106
+ environment.SHIB_IDP_LOGOUT = '${SHIB_IDP_LOGOUT:-}';
107
+ environment.SHIB_SP_KEY = '${SHIB_SP_KEY:-}';
108
+ environment.SHIB_SP_CERT = '${SHIB_SP_CERT:-}';
112
109
  }
113
110
 
114
111
  // Add S3 config if enabled
@@ -130,12 +127,12 @@ function generateWordPressService(config, wpVolumeName) {
130
127
  wpConfigExtra += "define('SUBDOMAIN_INSTALL', false);\n";
131
128
  }
132
129
 
133
- if (config.services.s3proxy && config.s3) {
134
- wpConfigExtra += `define('S3_UPLOADS_BUCKET', '${config.s3.bucket || ''}');\n`;
135
- wpConfigExtra += `define('S3_UPLOADS_REGION', '${config.s3.region || 'us-east-1'}');\n`;
136
- wpConfigExtra += `define('S3_UPLOADS_KEY', '${config.s3.accessKeyId || ''}');\n`;
137
- wpConfigExtra += `define('S3_UPLOADS_SECRET', '${config.s3.secretAccessKey || ''}');\n`;
138
- wpConfigExtra += `define('ACCESS_RULES_TABLE', '${config.s3.accessRulesTable || ''}');\n`;
130
+ if (config.services.s3proxy) {
131
+ wpConfigExtra += "define('S3_UPLOADS_BUCKET', '${S3_UPLOADS_BUCKET}');\n";
132
+ wpConfigExtra += "define('S3_UPLOADS_REGION', '${S3_UPLOADS_REGION:-us-east-1}');\n";
133
+ wpConfigExtra += "define('S3_UPLOADS_KEY', '${S3_UPLOADS_ACCESS_KEY_ID}');\n";
134
+ wpConfigExtra += "define('S3_UPLOADS_SECRET', '${S3_UPLOADS_SECRET_ACCESS_KEY}');\n";
135
+ wpConfigExtra += "define('ACCESS_RULES_TABLE', '${S3_ACCESS_RULES_TABLE}');\n";
139
136
  wpConfigExtra += "define('S3_UPLOADS_OBJECT_ACL', null);\n";
140
137
  wpConfigExtra += "define('S3_UPLOADS_AUTOENABLE', true);\n";
141
138
  wpConfigExtra += "define('S3_UPLOADS_DISABLE_REPLACE_UPLOAD_URL', true);\n";
@@ -183,14 +180,10 @@ function generateWordPressService(config, wpVolumeName) {
183
180
 
184
181
  /**
185
182
  * Generate S3 proxy service configuration
186
- * @param {object} config - buwp-local configuration
183
+ * @param {object} _config - buwp-local configuration (unused - env vars used instead)
187
184
  * @returns {object} S3 proxy service config
188
185
  */
189
- function generateS3ProxyService(config) {
190
- const region = config.olap?.region || config.s3?.region || 'us-east-1';
191
- const olapName = config.olap?.name || '';
192
- const olapAcctNbr = config.olap?.accountNumber || '';
193
-
186
+ function generateS3ProxyService(_config) {
194
187
  return {
195
188
  image: 'public.ecr.aws/bostonuniversity-nonprod/aws-sigv4-proxy',
196
189
  restart: 'always',
@@ -199,16 +192,16 @@ function generateS3ProxyService(config) {
199
192
  '--name',
200
193
  's3-object-lambda',
201
194
  '--region',
202
- region,
195
+ '${OLAP_REGION:-us-east-1}',
203
196
  '--no-verify-ssl',
204
197
  '--host',
205
- `${olapName}-${olapAcctNbr}.s3-object-lambda.${region}.amazonaws.com`
198
+ '${OLAP}-${OLAP_ACCT_NBR}.s3-object-lambda.${OLAP_REGION:-us-east-1}.amazonaws.com'
206
199
  ],
207
200
  environment: {
208
201
  healthcheck_path: '/s3proxy-healthcheck',
209
- AWS_ACCESS_KEY_ID: config.s3?.accessKeyId || '',
210
- AWS_SECRET_ACCESS_KEY: config.s3?.secretAccessKey || '',
211
- REGION: region
202
+ AWS_ACCESS_KEY_ID: '${S3_UPLOADS_ACCESS_KEY_ID}',
203
+ AWS_SECRET_ACCESS_KEY: '${S3_UPLOADS_SECRET_ACCESS_KEY}',
204
+ REGION: '${S3_UPLOADS_REGION:-us-east-1}'
212
205
  },
213
206
  networks: ['wp-network']
214
207
  };
package/lib/config.js CHANGED
@@ -7,6 +7,7 @@ import fs from 'fs';
7
7
  import path from 'path';
8
8
  import chalk from 'chalk';
9
9
  import dotenv from 'dotenv';
10
+ import * as keychain from './keychain.js';
10
11
 
11
12
  const CONFIG_FILE_NAME = '.buwp-local.json';
12
13
  const ENV_FILE_NAME = '.env.local';
@@ -283,6 +284,96 @@ function sanitizeProjectName(name) {
283
284
  .replace(/^-+|-+$/g, ''); // Remove leading/trailing dashes
284
285
  }
285
286
 
287
+ /**
288
+ * Load credentials from macOS keychain
289
+ * Returns all 15 credentials if available, with platform and keychain support checks
290
+ * @returns {object} Credentials object or empty object if not supported
291
+ */
292
+ export function loadKeychainCredentials() {
293
+ // Only load on macOS where keychain is supported
294
+ if (!keychain.isPlatformSupported()) {
295
+ return {};
296
+ }
297
+
298
+ const credentials = {};
299
+
300
+ // Try to load each known credential from keychain
301
+ for (const key of keychain.CREDENTIAL_KEYS) {
302
+ const value = keychain.getCredential(key);
303
+ if (value !== null) {
304
+ credentials[key] = value;
305
+ }
306
+ }
307
+
308
+ return credentials;
309
+ }
310
+
311
+ /**
312
+ * Create a secure temporary .env file from credentials
313
+ * File is created with restrictive permissions (600) in /tmp
314
+ * @param {object} credentials - Credential key-value pairs
315
+ * @param {string} projectName - Project name for unique filename
316
+ * @returns {string} Path to created temp file
317
+ */
318
+ export function createSecureTempEnvFile(credentials, projectName) {
319
+ const timestamp = Date.now();
320
+ const tempPath = path.join('/tmp', `.buwp-local-${projectName}-${timestamp}.env`);
321
+
322
+ // Build env file content with proper escaping for multiline values
323
+ const lines = Object.entries(credentials).map(([key, value]) => {
324
+ // Escape special characters in values
325
+ const escaped = String(value)
326
+ .replace(/\\\\/g, '\\\\\\\\') // Escape backslashes first
327
+ .replace(/"/g, '\\"') // Escape double quotes
328
+ .replace(/\\n/g, '\\\\n'); // Escape newlines for shell
329
+
330
+ return `${key}="${escaped}"`;
331
+ });
332
+
333
+ const content = lines.join('\n') + '\n';
334
+
335
+ try {
336
+ // Atomic create with strict permissions: user read/write only (600)
337
+ // 'wx' means create exclusive (fail if exists) and writable
338
+ const fd = fs.openSync(tempPath, 'wx', 0o600);
339
+ fs.writeSync(fd, content);
340
+ fs.closeSync(fd);
341
+
342
+ return tempPath;
343
+ } catch (err) {
344
+ throw new Error(`Failed to create secure temp env file: ${err.message}`);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Securely delete a temporary env file
350
+ * Overwrites file contents with zeros before deletion to prevent recovery
351
+ * @param {string} filePath - Path to temp file to delete
352
+ * @returns {boolean} True if deleted successfully, false if error (non-fatal)
353
+ */
354
+ export function secureDeleteTempEnvFile(filePath) {
355
+ try {
356
+ // Check if file exists
357
+ if (!fs.existsSync(filePath)) {
358
+ return true; // Already gone, consider success
359
+ }
360
+
361
+ // Get file size and overwrite with zeros (secure deletion)
362
+ const stat = fs.statSync(filePath);
363
+ const fd = fs.openSync(filePath, 'r+');
364
+ fs.writeSync(fd, Buffer.alloc(stat.size, 0));
365
+ fs.closeSync(fd);
366
+
367
+ // Now delete the file
368
+ fs.unlinkSync(filePath);
369
+ return true;
370
+ } catch (err) {
371
+ // Log warning but don't fail - temp files are ephemeral anyway
372
+ console.warn(chalk.yellow(`⚠️ Warning: Could not securely delete temp file ${filePath}: ${err.message}`));
373
+ return false;
374
+ }
375
+ }
376
+
286
377
  export {
287
378
  loadConfig,
288
379
  validateConfig,
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import * as config from './config.js';
7
7
  import * as composeGenerator from './compose-generator.js';
8
+ import * as keychain from './keychain.js';
8
9
 
9
10
  export const loadConfig = config.loadConfig;
10
11
  export const validateConfig = config.validateConfig;
@@ -14,3 +15,17 @@ export const generateComposeFile = composeGenerator.generateComposeFile;
14
15
  export const CONFIG_FILE_NAME = config.CONFIG_FILE_NAME;
15
16
  export const ENV_FILE_NAME = config.ENV_FILE_NAME;
16
17
  export const DEFAULT_CONFIG = config.DEFAULT_CONFIG;
18
+
19
+ // Keychain exports
20
+ export const setCredential = keychain.setCredential;
21
+ export const getCredential = keychain.getCredential;
22
+ export const hasCredential = keychain.hasCredential;
23
+ export const listCredentials = keychain.listCredentials;
24
+ export const clearAllCredentials = keychain.clearAllCredentials;
25
+ export const isPlatformSupported = keychain.isPlatformSupported;
26
+ export const isMultilineCredential = keychain.isMultilineCredential;
27
+ export const parseCredentialsFile = keychain.parseCredentialsFile;
28
+ export const CREDENTIAL_KEYS = keychain.CREDENTIAL_KEYS;
29
+ export const CREDENTIAL_GROUPS = keychain.CREDENTIAL_GROUPS;
30
+ export const CREDENTIAL_DESCRIPTIONS = keychain.CREDENTIAL_DESCRIPTIONS;
31
+ export const MULTILINE_CREDENTIALS = keychain.MULTILINE_CREDENTIALS;
@@ -0,0 +1,339 @@
1
+ /**
2
+ * macOS Keychain integration for secure credential storage
3
+ * Uses the `security` command-line tool to interact with macOS Keychain
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import fs from 'fs';
8
+
9
+ // Keychain service name for all buwp-local credentials
10
+ const KEYCHAIN_SERVICE = 'buwp-local';
11
+
12
+ /**
13
+ * All credential keys that can be stored in keychain
14
+ */
15
+ export const CREDENTIAL_KEYS = [
16
+ 'WORDPRESS_DB_PASSWORD',
17
+ 'DB_ROOT_PASSWORD',
18
+ 'SP_ENTITY_ID',
19
+ 'IDP_ENTITY_ID',
20
+ 'SHIB_IDP_LOGOUT',
21
+ 'SHIB_SP_KEY',
22
+ 'SHIB_SP_CERT',
23
+ 'S3_UPLOADS_BUCKET',
24
+ 'S3_UPLOADS_REGION',
25
+ 'S3_UPLOADS_ACCESS_KEY_ID',
26
+ 'S3_UPLOADS_SECRET_ACCESS_KEY',
27
+ 'S3_ACCESS_RULES_TABLE',
28
+ 'OLAP',
29
+ 'OLAP_ACCT_NBR',
30
+ 'OLAP_REGION'
31
+ ];
32
+
33
+ /**
34
+ * Credentials organized by functional group
35
+ */
36
+ export const CREDENTIAL_GROUPS = {
37
+ database: ['WORDPRESS_DB_PASSWORD', 'DB_ROOT_PASSWORD'],
38
+ shibboleth: ['SP_ENTITY_ID', 'IDP_ENTITY_ID', 'SHIB_IDP_LOGOUT', 'SHIB_SP_KEY', 'SHIB_SP_CERT'],
39
+ s3: ['S3_UPLOADS_BUCKET', 'S3_UPLOADS_REGION', 'S3_UPLOADS_ACCESS_KEY_ID', 'S3_UPLOADS_SECRET_ACCESS_KEY', 'S3_ACCESS_RULES_TABLE'],
40
+ olap: ['OLAP', 'OLAP_ACCT_NBR', 'OLAP_REGION']
41
+ };
42
+
43
+ /**
44
+ * Credentials that contain multiline content (cryptographic keys, certificates)
45
+ */
46
+ export const MULTILINE_CREDENTIALS = ['SHIB_SP_KEY', 'SHIB_SP_CERT'];
47
+
48
+ /**
49
+ * Human-readable descriptions for each credential
50
+ */
51
+ export const CREDENTIAL_DESCRIPTIONS = {
52
+ WORDPRESS_DB_PASSWORD: 'WordPress database password',
53
+ DB_ROOT_PASSWORD: 'Database root password',
54
+ SP_ENTITY_ID: 'Shibboleth Service Provider Entity ID',
55
+ IDP_ENTITY_ID: 'Shibboleth Identity Provider Entity ID',
56
+ SHIB_IDP_LOGOUT: 'Shibboleth IDP logout URL',
57
+ SHIB_SP_KEY: 'Shibboleth Service Provider private key (multiline)',
58
+ SHIB_SP_CERT: 'Shibboleth Service Provider certificate (multiline)',
59
+ S3_UPLOADS_BUCKET: 'S3 bucket name',
60
+ S3_UPLOADS_REGION: 'S3 region (e.g., us-east-1)',
61
+ S3_UPLOADS_ACCESS_KEY_ID: 'AWS access key ID',
62
+ S3_UPLOADS_SECRET_ACCESS_KEY: 'AWS secret access key',
63
+ S3_ACCESS_RULES_TABLE: 'S3 access rules table name',
64
+ OLAP: 'OLAP name',
65
+ OLAP_ACCT_NBR: 'OLAP account number',
66
+ OLAP_REGION: 'OLAP region'
67
+ };
68
+
69
+ /**
70
+ * Check if the current platform supports keychain operations
71
+ * @returns {boolean} True if macOS, false otherwise
72
+ */
73
+ export function isPlatformSupported() {
74
+ return process.platform === 'darwin';
75
+ }
76
+
77
+ /**
78
+ * Validate that a credential key is recognized
79
+ * @param {string} key - Credential key to validate
80
+ * @returns {boolean} True if valid
81
+ */
82
+ export function isValidCredentialKey(key) {
83
+ return CREDENTIAL_KEYS.includes(key);
84
+ }
85
+
86
+ /**
87
+ * Get the group name for a credential key
88
+ * @param {string} key - Credential key
89
+ * @returns {string|null} Group name or null if not found
90
+ */
91
+ export function getCredentialGroup(key) {
92
+ for (const [groupName, keys] of Object.entries(CREDENTIAL_GROUPS)) {
93
+ if (keys.includes(key)) {
94
+ return groupName;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Check if a credential contains multiline content
102
+ * @param {string} key - Credential key
103
+ * @returns {boolean} True if credential is multiline
104
+ */
105
+ export function isMultilineCredential(key) {
106
+ return MULTILINE_CREDENTIALS.includes(key);
107
+ }
108
+
109
+ /**
110
+ * Set a credential in the macOS keychain
111
+ * Uses -U flag to update existing entries instead of creating duplicates
112
+ * @param {string} key - Credential key (e.g., 'WORDPRESS_DB_PASSWORD')
113
+ * @param {string} value - Credential value
114
+ * @throws {Error} If platform is not supported or operation fails
115
+ */
116
+ export function setCredential(key, value) {
117
+ if (!isPlatformSupported()) {
118
+ throw new Error('Keychain operations are only supported on macOS');
119
+ }
120
+
121
+ if (!isValidCredentialKey(key)) {
122
+ throw new Error(`Invalid credential key: ${key}`);
123
+ }
124
+
125
+ try {
126
+ // Use -U flag to update if exists, create if doesn't
127
+ // -w flag allows password on command line (needed for automation)
128
+ execSync(
129
+ `security add-generic-password -s "${KEYCHAIN_SERVICE}" -a "${key}" -w "${value}" -U`,
130
+ { stdio: 'pipe' }
131
+ );
132
+ } catch (err) {
133
+ throw new Error(`Failed to store credential in keychain: ${err.message}`);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Get a credential from the macOS keychain
139
+ * @param {string} key - Credential key
140
+ * @returns {string|null} Credential value or null if not found
141
+ * @throws {Error} If platform is not supported
142
+ */
143
+ export function getCredential(key) {
144
+ if (!isPlatformSupported()) {
145
+ throw new Error('Keychain operations are only supported on macOS');
146
+ }
147
+
148
+ if (!isValidCredentialKey(key)) {
149
+ throw new Error(`Invalid credential key: ${key}`);
150
+ }
151
+
152
+ try {
153
+ // -w flag returns only the password (no other metadata)
154
+ let result = execSync(
155
+ `security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${key}" -w`,
156
+ { stdio: 'pipe', encoding: 'utf8' }
157
+ );
158
+ result = result.trim();
159
+
160
+ // Handle hex-encoded values (legacy format for multiline credentials)
161
+ // Check if the value looks like hex (only hex digits, even length)
162
+ if (isMultilineCredential(key) && isHexEncoded(result)) {
163
+ try {
164
+ result = Buffer.from(result, 'hex').toString('utf8');
165
+ } catch (err) {
166
+ // If hex decode fails, return original value
167
+ console.warn(`Warning: Could not decode hex-encoded credential ${key}`);
168
+ }
169
+ }
170
+
171
+ return result;
172
+ } catch (err) {
173
+ // Credential not found
174
+ if (err.message.includes('could not be found')) {
175
+ return null;
176
+ }
177
+ throw new Error(`Failed to retrieve credential from keychain: ${err.message}`);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Check if a string appears to be hex-encoded
183
+ * @param {string} value - Value to check
184
+ * @returns {boolean} True if value looks like hex
185
+ */
186
+ function isHexEncoded(value) {
187
+ // Must be even length and only contain hex digits
188
+ return /^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0;
189
+ }
190
+
191
+ /**
192
+ * Check if a credential exists in the keychain
193
+ * @param {string} key - Credential key
194
+ * @returns {boolean} True if credential exists
195
+ */
196
+ export function hasCredential(key) {
197
+ if (!isPlatformSupported()) {
198
+ return false;
199
+ }
200
+
201
+ if (!isValidCredentialKey(key)) {
202
+ return false;
203
+ }
204
+
205
+ try {
206
+ execSync(
207
+ `security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${key}"`,
208
+ { stdio: 'pipe' }
209
+ );
210
+ return true;
211
+ } catch (err) {
212
+ return false;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Delete a credential from the keychain
218
+ * @param {string} key - Credential key
219
+ * @throws {Error} If platform is not supported or operation fails
220
+ */
221
+ export function deleteCredential(key) {
222
+ if (!isPlatformSupported()) {
223
+ throw new Error('Keychain operations are only supported on macOS');
224
+ }
225
+
226
+ if (!isValidCredentialKey(key)) {
227
+ throw new Error(`Invalid credential key: ${key}`);
228
+ }
229
+
230
+ try {
231
+ execSync(
232
+ `security delete-generic-password -s "${KEYCHAIN_SERVICE}" -a "${key}"`,
233
+ { stdio: 'pipe' }
234
+ );
235
+ } catch (err) {
236
+ // Ignore if credential doesn't exist
237
+ if (!err.message.includes('could not be found')) {
238
+ throw new Error(`Failed to delete credential from keychain: ${err.message}`);
239
+ }
240
+
241
+ console.log("foobar");
242
+ }
243
+ }
244
+
245
+ /**
246
+ * List all stored credentials (returns keys only, not values)
247
+ * @returns {string[]} Array of credential keys that are stored
248
+ */
249
+ export function listCredentials() {
250
+ if (!isPlatformSupported()) {
251
+ return [];
252
+ }
253
+
254
+ const storedKeys = [];
255
+
256
+ for (const key of CREDENTIAL_KEYS) {
257
+ if (hasCredential(key)) {
258
+ storedKeys.push(key);
259
+ }
260
+ }
261
+
262
+ return storedKeys;
263
+ }
264
+
265
+ /**
266
+ * Clear all buwp-local credentials from keychain
267
+ * @returns {number} Number of credentials deleted
268
+ */
269
+ export function clearAllCredentials() {
270
+ if (!isPlatformSupported()) {
271
+ throw new Error('Keychain operations are only supported on macOS');
272
+ }
273
+
274
+ let deletedCount = 0;
275
+
276
+ for (const key of CREDENTIAL_KEYS) {
277
+ try {
278
+ deleteCredential(key);
279
+ deletedCount++;
280
+ } catch (err) {
281
+ // Continue deleting others even if one fails
282
+ }
283
+ }
284
+
285
+ return deletedCount;
286
+ }
287
+
288
+ /**
289
+ * Parse credentials from a JSON file
290
+ * Expected format: { "credentials": { "KEY": "value", ... }, "version": "1.0", ... }
291
+ * @param {string} filePath - Path to credentials JSON file
292
+ * @returns {object} { parsed, unknown, metadata }
293
+ */
294
+ export function parseCredentialsFile(filePath) {
295
+ // Read and parse JSON file
296
+ let data;
297
+ try {
298
+ const content = fs.readFileSync(filePath, 'utf8');
299
+ data = JSON.parse(content);
300
+ } catch (err) {
301
+ if (err.code === 'ENOENT') {
302
+ throw new Error(`File not found: ${filePath}`);
303
+ } else if (err instanceof SyntaxError) {
304
+ throw new Error(`Invalid JSON format: ${err.message}`);
305
+ }
306
+ throw new Error(`Failed to read file: ${err.message}`);
307
+ }
308
+
309
+ // Validate structure
310
+ if (!data.credentials || typeof data.credentials !== 'object') {
311
+ throw new Error('Invalid credentials file format: missing "credentials" object');
312
+ }
313
+
314
+ // Filter and validate keys
315
+ const parsed = {};
316
+ const unknown = [];
317
+
318
+ for (const [key, value] of Object.entries(data.credentials)) {
319
+ if (CREDENTIAL_KEYS.includes(key)) {
320
+ // Validate value is non-empty string
321
+ if (typeof value === 'string' && value.trim().length > 0) {
322
+ parsed[key] = value;
323
+ } else {
324
+ unknown.push({ key, reason: 'empty or invalid value' });
325
+ }
326
+ } else {
327
+ unknown.push({ key, reason: 'unknown credential key' });
328
+ }
329
+ }
330
+
331
+ // Extract metadata
332
+ const metadata = {
333
+ version: data.version || 'unknown',
334
+ source: data.source || 'unknown',
335
+ exported: data.exported || null
336
+ };
337
+
338
+ return { parsed, unknown, metadata };
339
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bostonuniversity/buwp-local",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Local WordPress development environment for Boston University projects",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "test": "echo \"Error: no test specified\" && exit 1",
12
- "buwp-local": "node bin/buwp-local.js",
12
+ "buwp-local": "node bin/buwp-local.js start",
13
13
  "lint": "eslint ."
14
14
  },
15
15
  "keywords": [
@@ -0,0 +1,57 @@
1
+ ## Plan: Environment-Based Credentials Migration (v0.4.1)
2
+
3
+ We've successfully shipped v0.4.0 with interactive init, sandbox support, configurable Docker images, and smart hostname defaults. The codebase is in excellent shape with real-world testing validated in both plugin and sandbox scenarios.
4
+
5
+ **Current Problem:** Credentials (database passwords, AWS keys, Shibboleth certs) are currently being read from the config/environment and then **written directly into the generated `docker-compose.yml`**. This means sensitive data sits in a generated file, which isn't ideal for security.
6
+
7
+ **Better Approach:** Use Docker Compose's native environment variable interpolation (`${VAR_NAME}`) so credentials stay in `.env.local` and never get written to the compose file.
8
+
9
+ ### Steps
10
+
11
+ 1. **Update `compose-generator.js` to use environment variable references**
12
+ - Database service: Change from `MYSQL_PASSWORD: dbPassword` to `MYSQL_PASSWORD: '${WORDPRESS_DB_PASSWORD:-password}'`
13
+ - WordPress service: Change from `WORDPRESS_DB_PASSWORD: config.db?.password` to `WORDPRESS_DB_PASSWORD: '${WORDPRESS_DB_PASSWORD:-password}'`
14
+ - S3 proxy service: Change from `AWS_ACCESS_KEY_ID: config.s3?.accessKeyId` to `AWS_ACCESS_KEY_ID: '${S3_UPLOADS_ACCESS_KEY_ID}'`
15
+ - WordPress `WORDPRESS_CONFIG_EXTRA`: Change from injecting actual S3 keys to using `${S3_UPLOADS_ACCESS_KEY_ID}` references
16
+
17
+ 2. **Update `start.js` to pass `.env.local` to docker compose**
18
+ - Add `--env-file` flag to `docker compose up` command
19
+ - Ensure `.env.local` path is correctly resolved relative to project
20
+
21
+ 3. **Update config loading to remove credential reading**
22
+ - Keep `extractEnvVars()` for backward compatibility but mark as deprecated
23
+ - Config validation no longer needs to check for credential presence in config object
24
+ - Document that credentials should only live in `.env.local`
25
+
26
+ 4. **Update documentation**
27
+ - `USAGE.md` - Clarify that `.env.local` is the source of truth for credentials
28
+ - Add example showing compose file will have `${VAR}` references
29
+ - Security best practices section emphasizes this approach
30
+
31
+ 5. **Test migration path**
32
+ - Verify existing projects with credentials in config still work (backward compat)
33
+ - Test new projects using only `.env.local`
34
+ - Verify generated compose file contains variable references, not actual values
35
+ - Ensure `docker compose` properly reads `.env.local`
36
+
37
+ ### Further Considerations
38
+
39
+ 1. **Backward compatibility**: Should we warn users if credentials exist in config? Or silently prefer `.env.local`?
40
+ 2. **`.env.local` template**: Should we create `.env.local.example` during `init` command?
41
+ 3. **Validation**: Should `config --validate` check that `.env.local` exists and has required variables?
42
+
43
+ ---
44
+
45
+ **Why This Matters:**
46
+ - **Security**: Credentials never written to files, only referenced
47
+ - **Git safety**: Generated compose files are safe to accidentally commit (no secrets)
48
+ - **Best practices**: Aligns with Docker Compose's standard env var pattern
49
+ - **Simplicity**: Reduces complexity in config merging logic
50
+
51
+ **Effort Estimate:** Low-Medium (4-6 hours)
52
+ - Compose generation changes: ~2 hours
53
+ - Start command changes: ~1 hour
54
+ - Documentation: ~1 hour
55
+ - Testing: ~1-2 hours
56
+
57
+ **Ready to proceed?** This sets up a cleaner foundation before tackling Phase 2 features like keychain integration.