Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,12 @@ FIREBASE_appId=1:1070634963357:web:2a4abc725fff24a5199f74
# the measurement ID for the Firebase project
FIREBASE_measurementId=G-6LNX1KCR8E
# the server region for the Firebase project
FIREBASE_serverRegion=us-central1
FIREBASE_serverRegion=us-central1

# Brief brochure stub for offline CI (placeholders — not production CMUTT values)
BRIEF_NAME=[BETA]
BRIEF_API_BASE=https://api.mvtt.app
BRIEF_BRAND_COLOR=f5c14b
BRIEF_FIREBASE_APP_ID=1:1070634963357:ios:teststub00000000000000
BRIEF_FIREBASE_CLIENT_ID=1070634963357-teststub.apps.googleusercontent.com
BRIEF_FIREBASE_REVERSED_CLIENT_ID=com.googleusercontent.apps.1070634963357-teststub
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ node_modules
.env.local
.env.*.local

# Generated at build/deploy from BRIEF_* / FIREBASE_* / VUE_APP_* (see scripts/generate-brief-config.js)
/public/.well-known/brief.json

# Log files
npm-debug.log*
yarn-debug.log*
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ use `npm run serve -- --mode [mode]` to run with alternative `.env.[mode]` or `.
nvm use 18 && npm run deploy
```

Production Brief discovery (`/.well-known/brief.json` on `cmutt.app`) is **generated at deploy time** — never a committed production JSON file. `deploy.js` runs `scripts/generate-brief-config.js` before `vue-cli-service build`, then publishes `dist/` to `gh-pages`. CI `npm run build` also regenerates via `prebuild`, but **live Pages only updates when you run `npm run deploy`**.

**Production cmutt (preferred SSOT):** copy brochure config from the live API so humans only edit server env (e.g. mvtt-server `.env.cmu-tt`). In the deploy `.env` / `.env.local`:

```
BRIEF_FETCH_CONFIG=1
BRIEF_CONFIG_URL=https://api.cmutt.app/api/v1/client-config
```

Discrete `BRIEF_*` (and `.env.test`) remain for offline CI and forks — see `example.env`. Never commit service accounts; Firebase client fields are OK.

or optionally when cmu-tt is side-by-side (requires all changes to be pushed to remote for full parity):

```
Expand Down
8 changes: 8 additions & 0 deletions deploy.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ function deploy() {

centerMsg("\x1b[35m\x1b[2m%s\x1b[0m", "NOTE: JSDocs may lose content during compiling");
}
// Refresh Brief discovery JSON from env before build (also runs via npm prebuild).
// deploy calls vue-cli-service directly, so invoke the generator explicitly here.
runCommand(
`node scripts/generate-brief-config.js${mode ? ` --mode ${mode}` : ""}`,
"brief-config",
"Generating public/.well-known/brief.json from env"
);

runCommand(
`vue-cli-service build${mode ? ` --mode ${mode}` : ""}`,
"build",
Expand Down
46 changes: 45 additions & 1 deletion example.env
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,48 @@ FIREBASE_appId=1:1000000000000:web:AAAAAAAAAAAAAAAAAAAAAA
# the measurement ID for the Firebase project
FIREBASE_measurementId=G-AAAAAAAAAA
# the server region for the Firebase project
FIREBASE_serverRegion=us-central1
FIREBASE_serverRegion=us-central1

# =============================================================================
# Brief client-config (public/.well-known/brief.json) — generated at build/deploy
# =============================================================================
# NEVER commit real secrets. These Firebase *client* fields are public (same
# values as in the iOS plist / web SDK). Do NOT put service accounts, Admin SDK
# keys, or shared API secrets here. Never commit a hand-edited production
# brief.json — it is always generated.
#
# --- Production cmutt deploy (PREFERRED) — Option C: fetch from live API ---
# Humans edit brochure values only in mvtt-server `.env.cmu-tt` (or equivalent).
# Pages copies GET /api/v1/client-config into /.well-known/brief.json at deploy.
# Put these in the *production* deploy env (e.g. .env / .env.local used by
# `npm run deploy`), not in this example or in .env.test:
#
# BRIEF_FETCH_CONFIG=1
# BRIEF_CONFIG_URL=https://api.cmutt.app/api/v1/client-config
#
# --- Offline CI / forks — Option B: discrete BRIEF_* (below) ---
# .env.test uses these so `npm run build` works without network. Forks without
# a live API can keep composing locally the same way.
#
# --- Optional — Option A: single JSON blob ---
# BRIEF_CLIENT_CONFIG={"name":"ORGTT","apiBase":"https://api.example.com","brandColor":"#f5c14b","firebase":{"apiKey":"...","projectId":"...","appId":"1:...:ios:...","gcmSenderId":"...","clientId":"...","reversedClientId":"...","storageBucket":"...","databaseURL":null,"authDomain":"..."}}
#
# Option B: compose from discrete vars (reuses FIREBASE_* / VUE_APP_* where they match)
BRIEF_NAME=ORGTT
BRIEF_API_BASE=https://api.example.com
BRIEF_BRAND_COLOR=f5c14b
# iOS-only Brief fields (do NOT reuse web FIREBASE_appId)
BRIEF_FIREBASE_APP_ID=1:1000000000000:ios:AAAAAAAAAAAAAAAAAAAAAA
BRIEF_FIREBASE_CLIENT_ID=1000000000000-xxxxxxxx.apps.googleusercontent.com
BRIEF_FIREBASE_REVERSED_CLIENT_ID=com.googleusercontent.apps.1000000000000-xxxxxxxx
# Optional overrides (default to FIREBASE_* / null):
# BRIEF_FIREBASE_API_KEY=
# BRIEF_FIREBASE_PROJECT_ID=
# BRIEF_FIREBASE_GCM_SENDER_ID=
# BRIEF_FIREBASE_STORAGE_BUCKET=
# BRIEF_FIREBASE_AUTH_DOMAIN=
# BRIEF_FIREBASE_DATABASE_URL=
#
# Option C (production cmutt — preferred; see block above):
# BRIEF_FETCH_CONFIG=1
# BRIEF_CONFIG_URL=https://api.cmutt.app/api/v1/client-config
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"description": "A more effective way to manage test schedules, long-term assignments, and exam stress.",
"private": true,
"scripts": {
"generate:brief-config": "node scripts/generate-brief-config.js",
"prebuild": "node scripts/generate-brief-config.js",
"preserve": "node scripts/generate-brief-config.js",
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
Expand Down
Empty file added public/.well-known/.gitkeep
Empty file.
16 changes: 0 additions & 16 deletions public/.well-known/brief.json

This file was deleted.

238 changes: 238 additions & 0 deletions scripts/generate-brief-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
#!/usr/bin/env node
/**
* Generate public/.well-known/brief.json from env (single source of truth).
*
* Priority:
* 1. If BRIEF_FETCH_CONFIG=1 — fetch BRIEF_CONFIG_URL (production cmutt:
* https://api.cmutt.app/api/v1/client-config). Server .env is the only
* place humans edit brochure values; Pages copies the live API response.
* 2. BRIEF_CLIENT_CONFIG — full JSON blob (same schema as API client-config)
* 3. Compose from discrete BRIEF_* / VUE_APP_* / FIREBASE_* vars
* (offline CI / .env.test / forks)
* 4. Minimal stub so offline CI still builds
*
* Never put service accounts or server secrets here — Firebase web/iOS client
* fields are intentionally public. Never commit a hand-edited production
* brief.json.
*/

const fs = require("fs");
const path = require("path");

const ROOT = path.resolve(__dirname, "..");
const OUT_PATH = path.join(ROOT, "public", ".well-known", "brief.json");

const DEFAULT_FETCH_URL = "https://api.cmutt.app/api/v1/client-config";

/** Parse a simple KEY=VALUE .env file (no export, no multiline values). */
function parseEnvFile(filePath) {
if (!fs.existsSync(filePath)) return {};
const out = {};
for (const raw of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
out[key] = value;
}
return out;
}

/**
* Load Vue-CLI-style env files for a mode without overwriting existing process.env.
* Order (lowest → highest precedence among files): .env, .env.local, .env.[mode], .env.[mode].local
* Existing process.env wins over all files.
*/
function loadEnvFiles(mode) {
const names = [".env", ".env.local"];
if (mode && mode !== "development") {
names.push(`.env.${mode}`, `.env.${mode}.local`);
} else if (mode === "development") {
names.push(".env.development", ".env.development.local");
} else {
// production default when no mode (matches vue-cli-service build)
names.push(".env.production", ".env.production.local");
}

const merged = {};
for (const name of names) {
Object.assign(merged, parseEnvFile(path.join(ROOT, name)));
}
for (const [key, value] of Object.entries(merged)) {
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
}

function argMode() {
const idx = process.argv.indexOf("--mode");
if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1];
// npm run build -- --mode test → vue-cli gets it; prebuild may see npm_config_argv
const fromEnv = process.env.VUE_CLI_MODE || process.env.npm_config_mode;
if (fromEnv) return fromEnv;
// CI copies .env.test → .env; treat as production build mode for file loading
return process.env.NODE_ENV === "development" ? "development" : "production";
}

function withHash(color) {
if (!color) return undefined;
return color.startsWith("#") ? color : `#${color}`;
}

function apiBaseFromEnv() {
if (process.env.BRIEF_API_BASE) return process.env.BRIEF_API_BASE.replace(/\/$/, "");
const host = process.env.VUE_APP_BRAND_DOMAIN__API;
if (host) return `https://${host.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
return undefined;
}

function composeFromDiscrete() {
const name =
process.env.BRIEF_NAME ||
process.env.VUE_APP_BRAND_NAME_SHORT ||
process.env.VUE_APP_BRAND_NAME_LONG;
const apiBase = apiBaseFromEnv();
const brandColor =
withHash(process.env.BRIEF_BRAND_COLOR) || withHash(process.env.VUE_APP_THEME_COLOR);

const firebase = {
apiKey: process.env.BRIEF_FIREBASE_API_KEY || process.env.FIREBASE_apiKey || null,
projectId: process.env.BRIEF_FIREBASE_PROJECT_ID || process.env.FIREBASE_projectId || null,
// iOS Brief appId — do NOT reuse web FIREBASE_appId
appId: process.env.BRIEF_FIREBASE_APP_ID || null,
gcmSenderId:
process.env.BRIEF_FIREBASE_GCM_SENDER_ID ||
process.env.FIREBASE_messagingSenderId ||
null,
clientId: process.env.BRIEF_FIREBASE_CLIENT_ID || null,
reversedClientId: process.env.BRIEF_FIREBASE_REVERSED_CLIENT_ID || null,
storageBucket:
process.env.BRIEF_FIREBASE_STORAGE_BUCKET || process.env.FIREBASE_storageBucket || null,
databaseURL:
process.env.BRIEF_FIREBASE_DATABASE_URL === undefined
? null
: process.env.BRIEF_FIREBASE_DATABASE_URL || null,
authDomain: process.env.BRIEF_FIREBASE_AUTH_DOMAIN || process.env.FIREBASE_authDomain || null,
};

if (!name && !apiBase && !Object.values(firebase).some((v) => v != null && v !== "")) {
return null;
}

return {
name: name || "Task Tracker",
apiBase: apiBase || "",
brandColor: brandColor || "#000000",
firebase,
};
}

function minimalStub() {
return {
name: process.env.VUE_APP_BRAND_NAME_SHORT || "Task Tracker",
apiBase: apiBaseFromEnv() || "",
brandColor: withHash(process.env.VUE_APP_THEME_COLOR) || "#000000",
firebase: {
apiKey: process.env.FIREBASE_apiKey || null,
projectId: process.env.FIREBASE_projectId || null,
appId: null,
gcmSenderId: process.env.FIREBASE_messagingSenderId || null,
clientId: null,
reversedClientId: null,
storageBucket: process.env.FIREBASE_storageBucket || null,
databaseURL: null,
authDomain: process.env.FIREBASE_authDomain || null,
},
};
}

function parseClientConfigBlob() {
const raw = process.env.BRIEF_CLIENT_CONFIG;
if (!raw || !raw.trim()) return null;
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
throw new Error("BRIEF_CLIENT_CONFIG must be a JSON object");
}
return parsed;
} catch (err) {
console.error(`[generate-brief-config] Invalid BRIEF_CLIENT_CONFIG: ${err.message}`);
process.exit(1);
}
}

async function fetchRemoteConfig() {
const url = process.env.BRIEF_CONFIG_URL || DEFAULT_FETCH_URL;
console.log(`[generate-brief-config] Fetching ${url}`);
const res = await fetch(url, {
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} from ${url}`);
}
return res.json();
}

function writeConfig(config) {
fs.mkdirSync(path.dirname(OUT_PATH), { recursive: true });
fs.writeFileSync(OUT_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
console.log(`[generate-brief-config] Wrote ${path.relative(ROOT, OUT_PATH)}`);
}

function wantFetch() {
return process.env.BRIEF_FETCH_CONFIG === "1" || process.env.BRIEF_FETCH_CONFIG === "true";
}

async function main() {
const mode = argMode();
loadEnvFiles(mode);

let config = null;
let source = null;

// Production cmutt: prefer live API so server .env is the only human SSOT.
if (wantFetch()) {
try {
config = await fetchRemoteConfig();
source = "remote fetch (BRIEF_FETCH_CONFIG)";
} catch (err) {
console.warn(`[generate-brief-config] Fetch failed: ${err.message}`);
}
}

if (!config) {
config = parseClientConfigBlob();
if (config) source = "BRIEF_CLIENT_CONFIG";
}

if (!config) {
config = composeFromDiscrete();
if (config) {
source = wantFetch()
? "env compose (fetch failed; BRIEF_* / VUE_APP_* / FIREBASE_*)"
: "env compose (BRIEF_* / VUE_APP_* / FIREBASE_*)";
}
}

if (!config) {
config = minimalStub();
source = wantFetch() ? "minimal stub (fetch failed)" : "minimal stub";
}

writeConfig(config);
console.log(`[generate-brief-config] Source: ${source}`);
}

main().catch((err) => {
console.error(`[generate-brief-config] ${err.stack || err}`);
process.exit(1);
});
Loading