@enderrealmmc/cf-static-guard 0.1.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/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # @enderrealmmc/cf-static-guard
2
+
3
+ Deploy any static site behind GitHub OAuth on Cloudflare Workers — without cloning the monorepo.
4
+
5
+ - **Worker is bundled in this package** — install the CLI and deploy
6
+ - **No files written into your site repo** — profiles live in `~/.config/csg` (or `%APPDATA%/csg`)
7
+ - **Rules & branding on Cloudflare** — edit via `/admin` or `csg config`
8
+ - Login + admin UIs included (Vue 3, prebuilt)
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm i -g @enderrealmmc/cf-static-guard
14
+ # requires Node.js >= 20
15
+ ```
16
+
17
+ You also need a Cloudflare account. The CLI depends on [wrangler](https://developers.cloudflare.com/workers/wrangler/).
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ csg auth # wrangler login (browser)
23
+ csg init # interactive: worker name, GitHub OAuth, secrets, KV
24
+ csg deploy --site ./dist # your static build output
25
+ ```
26
+
27
+ Open the custom domain or workers.dev URL, sign in with GitHub, then manage rules at `/admin`.
28
+
29
+ ### Non-interactive / CI
30
+
31
+ ```bash
32
+ export CSG_GITHUB_CLIENT_ID=...
33
+ export CSG_GITHUB_CLIENT_SECRET=...
34
+ export CSG_SESSION_SECRET=... # optional; auto-generated if unset
35
+ export CSG_ADMIN_PASSWORD=... # optional; auto-generated if unset
36
+
37
+ csg init \
38
+ --name my-docs \
39
+ --mode strict \
40
+ --non-interactive \
41
+ --create-kv \
42
+ --custom-domain docs.example.com \
43
+ --redirect-base https://docs.example.com
44
+
45
+ csg deploy --site ./docs/.vitepress/dist
46
+ ```
47
+
48
+ ## Everyday commands
49
+
50
+ | Command | What it does |
51
+ |---------|----------------|
52
+ | `csg deploy --site <dir>` | Deploy/refresh Worker + static assets (temp dir only) |
53
+ | `csg config get` | Show local profile + try pull KV |
54
+ | `csg config set-mode strict\|normal` | Change mode and push KV |
55
+ | `csg config allow-add <login>` | Allowlist a GitHub login |
56
+ | `csg config org-add <org>` | Require org membership (strict) |
57
+ | `csg config ui set icp "京ICP备..."` | Branding / ICP text |
58
+ | `csg secrets set` | Set Worker secrets |
59
+ | `csg kv create` | Create `RULES` KV namespace |
60
+ | `csg profile list\|use` | Multiple workers |
61
+ | `csg open admin --base https://...` | Open admin panel |
62
+ | `csg doctor` | Check login, bundle, config |
63
+
64
+ ## Where things live
65
+
66
+ | Data | Location |
67
+ |------|----------|
68
+ | Worker name, KV id, local rule draft | Global config (`~/.config/csg` or `%APPDATA%/csg`) |
69
+ | OAuth secrets, session, admin password | Cloudflare Worker Secrets |
70
+ | Strict rules, UI texts, ICP | Cloudflare KV (`auth:config`, `ui:config`) |
71
+ | Your website files | Your machine; only used as `--site` input |
72
+
73
+ **This CLI does not create `csg.config.json` in your project.**
74
+
75
+ ## Strict mode rules
76
+
77
+ Per GitHub provider (fields you leave empty are skipped; filled fields that fail deny access):
78
+
79
+ - `allowlist` / `blocklist`
80
+ - `minAccountAgeDays`
81
+ - `requiredOrgs`
82
+ - `requiredTeams` (`org/team`)
83
+ - `emailDomainAllowlist`
84
+
85
+ `blocklist` applies in both normal and strict mode.
86
+
87
+ ## GitHub OAuth App
88
+
89
+ Create one under your user or org:
90
+
91
+ | Field | Value |
92
+ |-------|--------|
93
+ | Homepage URL | `https://<your-domain>` |
94
+ | Callback URL | `https://<your-domain>/auth/callback/github` |
95
+
96
+ Then pass Client ID/Secret to `csg init` or `csg secrets set`.
97
+
98
+ ## Custom domain
99
+
100
+ ```bash
101
+ csg init --custom-domain docs.example.com --redirect-base https://docs.example.com ...
102
+ ```
103
+
104
+ Zone must already be on the same Cloudflare account. First attach may take a minute for DNS/TLS.
105
+
106
+ ## Admin panel
107
+
108
+ After deploy: `https://<your-domain>/admin`
109
+
110
+ Password is `ADMIN_PASSWORD` (set during `init`). Use it to edit rules and login-page text without redeploying static assets.
111
+
112
+ Update site content anytime:
113
+
114
+ ```bash
115
+ csg deploy --site ./path/to/dist
116
+ ```
117
+
118
+ ## Monorepo development
119
+
120
+ This package is built from [EnderRealmMC/cf-static-guard](https://github.com/EnderRealmMC/cf-static-guard).
121
+ `prepack` compiles the CLI and copies the prebuilt Worker into `dist-worker/`.
122
+
123
+ ## License
124
+
125
+ MIT
package/bin/csg.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../dist/index.js';
3
+
4
+ run(process.argv).catch((err) => {
5
+ console.error(err instanceof Error ? err.message : err);
6
+ process.exit(1);
7
+ });
@@ -0,0 +1,16 @@
1
+ import { runWranglerInherit } from '../lib/wrangler.js';
2
+ import { success, info } from '../lib/log.js';
3
+ export function registerAuth(program) {
4
+ program
5
+ .command('auth')
6
+ .description('Log in to Cloudflare via wrangler (opens browser)')
7
+ .action(async () => {
8
+ info('Running wrangler login…');
9
+ const code = await runWranglerInherit(['login']);
10
+ if (code !== 0) {
11
+ process.exitCode = code;
12
+ return;
13
+ }
14
+ success('Cloudflare login OK');
15
+ });
16
+ }
@@ -0,0 +1,253 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { getProfile, loadStore, upsertProfile, } from '../lib/store.js';
4
+ import { error, success, warn, info } from '../lib/log.js';
5
+ import { applyAuthToProfile, applyUiToProfile, profileToAuthConfig, profileToUiConfig, pullAuthConfig, pullUiConfig, pushAuthConfig, pushUiConfig, } from '../lib/kv.js';
6
+ function requireProfile(name) {
7
+ const p = getProfile(name);
8
+ if (!p) {
9
+ throw new Error('No profile. Run `csg init` first.');
10
+ }
11
+ return p;
12
+ }
13
+ function save(p) {
14
+ const store = loadStore();
15
+ upsertProfile(p, store.defaultProfile === p.name || !store.profiles[p.name]);
16
+ }
17
+ function rulesOf(p) {
18
+ if (!p.providers.github) {
19
+ p.providers.github = { enabled: true, rules: {} };
20
+ }
21
+ if (!p.providers.github.rules)
22
+ p.providers.github.rules = {};
23
+ return p.providers.github.rules;
24
+ }
25
+ export function registerConfig(program) {
26
+ const config = program
27
+ .command('config')
28
+ .description('Read/write live KV config (same JSON as /admin). Does not write into your site repo.');
29
+ config
30
+ .command('get')
31
+ .description('Print local profile + try pull online auth/ui')
32
+ .option('-p, --profile <name>', 'profile name')
33
+ .action(async (opts) => {
34
+ const p = requireProfile(opts.profile);
35
+ console.log(JSON.stringify({ local: p }, null, 2));
36
+ if (!p.kvNamespaceId) {
37
+ warn('No KV id — skip online pull');
38
+ return;
39
+ }
40
+ try {
41
+ const auth = await pullAuthConfig(p);
42
+ const ui = await pullUiConfig(p);
43
+ console.log(JSON.stringify({ online: { auth, ui } }, null, 2));
44
+ }
45
+ catch (e) {
46
+ warn(e instanceof Error ? e.message : String(e));
47
+ }
48
+ });
49
+ config
50
+ .command('set-mode <mode>')
51
+ .description('Set normal|strict on profile and push to KV')
52
+ .option('-p, --profile <name>', 'profile name')
53
+ .option('--no-push', 'Only update local profile')
54
+ .action(async (mode, opts) => {
55
+ if (mode !== 'normal' && mode !== 'strict') {
56
+ error('mode must be normal or strict');
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+ const p = requireProfile(opts.profile);
61
+ p.mode = mode;
62
+ p.updatedAt = new Date().toISOString();
63
+ save(p);
64
+ success(`Local mode → ${mode}`);
65
+ if (opts.push !== false && p.kvNamespaceId) {
66
+ await pushAuthConfig(p);
67
+ success('Pushed auth:config to KV');
68
+ }
69
+ });
70
+ config
71
+ .command('allow-add <login>')
72
+ .description('Add login to GitHub allowlist and push')
73
+ .option('-p, --profile <name>')
74
+ .option('--no-push')
75
+ .action(async (login, opts) => {
76
+ const p = requireProfile(opts.profile);
77
+ const rules = rulesOf(p);
78
+ rules.allowlist = rules.allowlist || [];
79
+ if (!rules.allowlist.includes(login))
80
+ rules.allowlist.push(login);
81
+ p.updatedAt = new Date().toISOString();
82
+ save(p);
83
+ success(`allowlist += ${login}`);
84
+ if (opts.push !== false && p.kvNamespaceId) {
85
+ await pushAuthConfig(p);
86
+ success('Pushed auth:config');
87
+ }
88
+ });
89
+ config
90
+ .command('allow-remove <login>')
91
+ .description('Remove login from allowlist and push')
92
+ .option('-p, --profile <name>')
93
+ .option('--no-push')
94
+ .action(async (login, opts) => {
95
+ const p = requireProfile(opts.profile);
96
+ const rules = rulesOf(p);
97
+ rules.allowlist = (rules.allowlist || []).filter((x) => x !== login);
98
+ p.updatedAt = new Date().toISOString();
99
+ save(p);
100
+ success(`allowlist -= ${login}`);
101
+ if (opts.push !== false && p.kvNamespaceId) {
102
+ await pushAuthConfig(p);
103
+ success('Pushed auth:config');
104
+ }
105
+ });
106
+ config
107
+ .command('org-add <org>')
108
+ .description('Add required GitHub org and push')
109
+ .option('-p, --profile <name>')
110
+ .option('--no-push')
111
+ .action(async (org, opts) => {
112
+ const p = requireProfile(opts.profile);
113
+ const rules = rulesOf(p);
114
+ rules.requiredOrgs = rules.requiredOrgs || [];
115
+ if (!rules.requiredOrgs.includes(org))
116
+ rules.requiredOrgs.push(org);
117
+ p.mode = p.mode === 'normal' ? 'strict' : p.mode;
118
+ p.updatedAt = new Date().toISOString();
119
+ save(p);
120
+ success(`requiredOrgs += ${org}`);
121
+ if (opts.push !== false && p.kvNamespaceId) {
122
+ await pushAuthConfig(p);
123
+ success('Pushed auth:config');
124
+ }
125
+ });
126
+ config
127
+ .command('ui')
128
+ .description('UI text helpers')
129
+ .argument('<key>', 'e.g. loginHeading, copyright, icp, footerBrand, siteTitle')
130
+ .argument('[value]', 'new value; omit to print current')
131
+ .option('-p, --profile <name>')
132
+ .option('--no-push')
133
+ .action(async (key, value, opts) => {
134
+ const p = requireProfile(opts.profile);
135
+ p.ui = p.ui || {};
136
+ const allowed = new Set([
137
+ 'siteTitle',
138
+ 'siteSubtitle',
139
+ 'loginHeading',
140
+ 'loginSubtitle',
141
+ 'providerButtonPrefix',
142
+ 'footerBrand',
143
+ 'copyright',
144
+ 'icp',
145
+ 'icpLink',
146
+ ]);
147
+ const boolKeys = new Set([
148
+ 'showModeBadge',
149
+ 'showFooterBrand',
150
+ 'showGithubIcon',
151
+ ]);
152
+ if (!allowed.has(key) && !boolKeys.has(key)) {
153
+ error(`Unknown ui key: ${key}`);
154
+ process.exitCode = 1;
155
+ return;
156
+ }
157
+ if (value === undefined) {
158
+ console.log(p.ui[key]);
159
+ return;
160
+ }
161
+ if (boolKeys.has(key)) {
162
+ p.ui[key] =
163
+ value === 'true' || value === '1' || value === 'yes';
164
+ }
165
+ else {
166
+ p.ui[key] = value;
167
+ }
168
+ p.updatedAt = new Date().toISOString();
169
+ save(p);
170
+ success(`ui.${key} = ${value}`);
171
+ if (opts.push !== false && p.kvNamespaceId) {
172
+ await pushUiConfig(p);
173
+ success('Pushed ui:config');
174
+ }
175
+ });
176
+ config
177
+ .command('pull')
178
+ .description('Pull online auth/ui into local profile (still not in your site repo)')
179
+ .option('-p, --profile <name>')
180
+ .option('--apply', 'Merge into local profile and save', true)
181
+ .action(async (opts) => {
182
+ const p = requireProfile(opts.profile);
183
+ if (!p.kvNamespaceId) {
184
+ error('No KV id on profile');
185
+ process.exitCode = 1;
186
+ return;
187
+ }
188
+ const auth = await pullAuthConfig(p);
189
+ const ui = await pullUiConfig(p);
190
+ let next = p;
191
+ if (auth)
192
+ next = applyAuthToProfile(next, auth);
193
+ if (ui)
194
+ next = applyUiToProfile(next, ui);
195
+ if (opts.apply !== false) {
196
+ save(next);
197
+ success('Local profile updated from KV');
198
+ }
199
+ console.log(JSON.stringify({ auth, ui }, null, 2));
200
+ });
201
+ config
202
+ .command('push')
203
+ .description('Push local profile auth+ui to KV')
204
+ .option('-p, --profile <name>')
205
+ .option('--json <file>', 'Optional: read auth config JSON from file instead of profile')
206
+ .action(async (opts) => {
207
+ const p = requireProfile(opts.profile);
208
+ if (!p.kvNamespaceId) {
209
+ error('No KV id on profile');
210
+ process.exitCode = 1;
211
+ return;
212
+ }
213
+ if (opts.json) {
214
+ const raw = readFileSync(resolve(process.cwd(), opts.json), 'utf8');
215
+ const parsed = JSON.parse(raw);
216
+ await pushAuthConfig({
217
+ ...p,
218
+ mode: parsed.mode || p.mode,
219
+ providers: {
220
+ github: {
221
+ enabled: parsed.providers?.github?.enabled !== false,
222
+ mode: parsed.providers?.github?.mode,
223
+ rules: parsed.providers?.github?.rules || {},
224
+ },
225
+ },
226
+ });
227
+ await pushUiConfig(p);
228
+ success(`Pushed auth from ${opts.json} + ui from profile`);
229
+ return;
230
+ }
231
+ await pushAuthConfig(p);
232
+ await pushUiConfig(p);
233
+ success('Pushed auth:config + ui:config');
234
+ info(`Local snapshot: ${JSON.stringify(profileToAuthConfig(p))}`);
235
+ info(`UI snapshot: ${JSON.stringify(profileToUiConfig(p))}`);
236
+ });
237
+ config
238
+ .command('export')
239
+ .description('Print a backup JSON of local profile to stdout')
240
+ .option('-p, --profile <name>')
241
+ .option('-o, --output <file>', 'Write to file instead of stdout')
242
+ .action((opts) => {
243
+ const p = requireProfile(opts.profile);
244
+ const json = JSON.stringify(p, null, 2) + '\n';
245
+ if (opts.output) {
246
+ writeFileSync(resolve(process.cwd(), opts.output), json, 'utf8');
247
+ success(`Wrote ${opts.output}`);
248
+ }
249
+ else {
250
+ process.stdout.write(json);
251
+ }
252
+ });
253
+ }
@@ -0,0 +1,65 @@
1
+ import { resolve } from 'node:path';
2
+ import { getProfile, loadStore } from '../lib/store.js';
3
+ import { error, info, step, success, warn } from '../lib/log.js';
4
+ import { prepareDeployDir } from '../lib/deploy.js';
5
+ import { runWranglerInherit } from '../lib/wrangler.js';
6
+ import { pushAuthConfig, pushUiConfig } from '../lib/kv.js';
7
+ export function registerDeploy(program) {
8
+ program
9
+ .command('deploy')
10
+ .description('Deploy/refresh worker + static assets (temp dir only; does not write into your project)')
11
+ .requiredOption('-s, --site <dir>', 'Path to static build output (dist)')
12
+ .option('-p, --profile <name>', 'Profile name (default: global default)')
13
+ .option('-m, --mode <mode>', 'Override mode for this deploy: normal|strict')
14
+ .option('--spa', 'Force SPA fallback on for this deploy')
15
+ .option('--push-config', 'Also push auth/ui config from profile to KV')
16
+ .option('--dry-run', 'Prepare temp dir and run wrangler deploy --dry-run')
17
+ .action(async (opts) => {
18
+ const store = loadStore();
19
+ const profile = getProfile(opts.profile);
20
+ if (!profile) {
21
+ error(opts.profile
22
+ ? `Profile not found: ${opts.profile}`
23
+ : 'No profile. Run `csg init` first.');
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+ const site = resolve(process.cwd(), opts.site);
28
+ const effective = {
29
+ ...profile,
30
+ mode: opts.mode === 'strict' ? 'strict' : opts.mode === 'normal' ? 'normal' : profile.mode,
31
+ spaFallback: opts.spa ? true : profile.spaFallback,
32
+ };
33
+ info(`Profile: ${effective.name} (default=${store.defaultProfile})`);
34
+ info(`Site: ${site}`);
35
+ info(`Mode: ${effective.mode}`);
36
+ if (!effective.kvNamespaceId) {
37
+ warn('KV not configured — admin/config online edits will not persist.');
38
+ }
39
+ step('Preparing temp deploy directory…');
40
+ const dirs = prepareDeployDir(effective, site);
41
+ try {
42
+ info(`Temp: ${dirs.root}`);
43
+ step('Running wrangler deploy…');
44
+ const code = await runWranglerInherit(opts.dryRun
45
+ ? ['deploy', '-c', 'wrangler.toml', '--dry-run']
46
+ : ['deploy', '-c', 'wrangler.toml'], { cwd: dirs.root });
47
+ if (code !== 0) {
48
+ error('wrangler deploy failed');
49
+ process.exitCode = code;
50
+ return;
51
+ }
52
+ success('Deploy finished');
53
+ if (opts.pushConfig && effective.kvNamespaceId) {
54
+ step('Pushing profile config to KV…');
55
+ await pushAuthConfig(effective);
56
+ await pushUiConfig(effective);
57
+ success('KV config updated');
58
+ }
59
+ success(`https://${effective.name}.\${your-subdomain}.workers.dev (or your custom domain)`);
60
+ }
61
+ finally {
62
+ dirs.cleanup();
63
+ }
64
+ });
65
+ }
@@ -0,0 +1,41 @@
1
+ import { error, info, success, warn } from '../lib/log.js';
2
+ import { cliVersion, hasWorkerBundle } from '../lib/paths.js';
3
+ import { configPath, listProfileNames, loadStore } from '../lib/store.js';
4
+ import { whoami } from '../lib/cf.js';
5
+ import { wranglerBin } from '../lib/wrangler.js';
6
+ export function registerDoctor(program) {
7
+ program
8
+ .command('doctor')
9
+ .description('Check Node, wrangler login, worker bundle, and global config')
10
+ .action(async () => {
11
+ info(`csg ${cliVersion()}`);
12
+ info(`node ${process.version}`);
13
+ info(`wrangler bin: ${wranglerBin()}`);
14
+ if (hasWorkerBundle()) {
15
+ success('worker bundle present (dist-worker/worker.js)');
16
+ }
17
+ else {
18
+ warn('worker bundle missing — reinstall package or run monorepo build');
19
+ }
20
+ const store = loadStore();
21
+ info(`config: ${configPath()}`);
22
+ const names = listProfileNames();
23
+ if (names.length === 0) {
24
+ warn('no profiles yet — run `csg init`');
25
+ }
26
+ else {
27
+ success(`profiles: ${names.join(', ')} (default: ${store.defaultProfile})`);
28
+ }
29
+ const auth = await whoami();
30
+ if (auth.ok) {
31
+ success('wrangler authenticated');
32
+ console.log(auth.text);
33
+ }
34
+ else {
35
+ error('wrangler not logged in — run `csg auth`');
36
+ if (auth.text)
37
+ console.log(auth.text);
38
+ process.exitCode = 1;
39
+ }
40
+ });
41
+ }