@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.
- package/.buwp-local.json +22 -0
- package/.env.local.example +26 -0
- package/IMPLEMENTATION_NOTES_V0.5.0_PHASE3.md +240 -0
- package/KEYCHAIN_IMPLEMENTATION.md +140 -0
- package/USAGE.md +31 -5
- package/bin/buwp-local.js +12 -0
- package/lib/commands/init.js +4 -0
- package/lib/commands/keychain.js +613 -0
- package/lib/commands/start.js +27 -2
- package/lib/compose-generator.js +21 -28
- package/lib/config.js +91 -0
- package/lib/index.js +15 -0
- package/lib/keychain.js +339 -0
- package/package.json +2 -2
- package/plan-environmentBasedCredentials.prompt.md +57 -0
package/lib/compose-generator.js
CHANGED
|
@@ -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:
|
|
71
|
-
MYSQL_ROOT_PASSWORD:
|
|
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:
|
|
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 =
|
|
108
|
-
environment.IDP_ENTITY_ID =
|
|
109
|
-
environment.SHIB_IDP_LOGOUT =
|
|
110
|
-
environment.SHIB_SP_KEY =
|
|
111
|
-
environment.SHIB_SP_CERT =
|
|
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
|
|
134
|
-
wpConfigExtra +=
|
|
135
|
-
wpConfigExtra +=
|
|
136
|
-
wpConfigExtra +=
|
|
137
|
-
wpConfigExtra +=
|
|
138
|
-
wpConfigExtra +=
|
|
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}
|
|
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(
|
|
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
|
-
|
|
195
|
+
'${OLAP_REGION:-us-east-1}',
|
|
203
196
|
'--no-verify-ssl',
|
|
204
197
|
'--host',
|
|
205
|
-
|
|
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:
|
|
210
|
-
AWS_SECRET_ACCESS_KEY:
|
|
211
|
-
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;
|
package/lib/keychain.js
ADDED
|
@@ -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.
|
|
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.
|