@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 +125 -0
- package/bin/csg.js +7 -0
- package/dist/commands/auth.js +16 -0
- package/dist/commands/config.js +253 -0
- package/dist/commands/deploy.js +65 -0
- package/dist/commands/doctor.js +41 -0
- package/dist/commands/init.js +257 -0
- package/dist/commands/kv.js +39 -0
- package/dist/commands/open.js +54 -0
- package/dist/commands/profile.js +69 -0
- package/dist/commands/secrets.js +72 -0
- package/dist/index.js +40 -0
- package/dist/lib/cf.js +22 -0
- package/dist/lib/deploy.js +75 -0
- package/dist/lib/kv.js +125 -0
- package/dist/lib/log.js +22 -0
- package/dist/lib/paths.js +29 -0
- package/dist/lib/store.js +76 -0
- package/dist/lib/wrangler.js +48 -0
- package/dist-worker/worker.js +2333 -0
- package/package.json +40 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { confirm, input, password, select } from '@inquirer/prompts';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { defaultProfile, getProfile, upsertProfile, } from '../lib/store.js';
|
|
4
|
+
import { box, error, step, success, warn } from '../lib/log.js';
|
|
5
|
+
import { createKvNamespace, putSecret, whoami } from '../lib/cf.js';
|
|
6
|
+
import { pushAuthConfig, pushUiConfig } from '../lib/kv.js';
|
|
7
|
+
export function registerInit(program) {
|
|
8
|
+
program
|
|
9
|
+
.command('init')
|
|
10
|
+
.description('Create/update a global profile and push first-time secrets (writes only to ~/.config/csg and Cloudflare)')
|
|
11
|
+
.option('-n, --name <worker>', 'Cloudflare worker name')
|
|
12
|
+
.option('-m, --mode <mode>', 'normal | strict', 'normal')
|
|
13
|
+
.option('--github-client-id <id>', 'GitHub OAuth client id')
|
|
14
|
+
.option('--github-client-secret <secret>', 'GitHub OAuth client secret')
|
|
15
|
+
.option('--session-secret <secret>', 'JWT session secret (auto-generated if omitted)')
|
|
16
|
+
.option('--admin-password <pwd>', 'Admin panel password (auto-generated if omitted)')
|
|
17
|
+
.option('--kv-id <id>', 'Existing KV namespace id')
|
|
18
|
+
.option('--create-kv', 'Create a new KV namespace named RULES')
|
|
19
|
+
.option('--spa', 'Enable SPA index fallback')
|
|
20
|
+
.option('--redirect-base <url>', 'OAuth redirect base origin')
|
|
21
|
+
.option('--custom-domain <host>', 'Attach custom domain to worker (e.g. docs.example.com)')
|
|
22
|
+
.option('--push', 'Push auth/ui config to KV after setup')
|
|
23
|
+
.option('--non-interactive', 'Fail instead of prompting when required values are missing')
|
|
24
|
+
.action(async (opts) => {
|
|
25
|
+
const interactive = !opts.nonInteractive;
|
|
26
|
+
const auth = await whoami();
|
|
27
|
+
if (!auth.ok) {
|
|
28
|
+
warn('Wrangler may not be logged in. Run `csg auth` if deploy fails.');
|
|
29
|
+
}
|
|
30
|
+
let name = opts.name;
|
|
31
|
+
if (!name && interactive) {
|
|
32
|
+
name = await input({
|
|
33
|
+
message: 'Worker name (subdomain on workers.dev)',
|
|
34
|
+
default: 'cf-static-guard',
|
|
35
|
+
validate: (v) => /^[a-z0-9-]+$/i.test(v) || 'Use letters, numbers, dashes',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (!name) {
|
|
39
|
+
error('--name is required in non-interactive mode');
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let mode = opts.mode === 'strict' ? 'strict' : 'normal';
|
|
44
|
+
if (interactive && !opts.nonInteractive) {
|
|
45
|
+
mode = await select({
|
|
46
|
+
message: 'Default access mode',
|
|
47
|
+
choices: [
|
|
48
|
+
{ name: 'normal — OAuth success is enough (blocklist still applies)', value: 'normal' },
|
|
49
|
+
{ name: 'strict — evaluate configured rules per provider', value: 'strict' },
|
|
50
|
+
],
|
|
51
|
+
default: mode,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const existing = getProfile(name);
|
|
55
|
+
const profile = existing
|
|
56
|
+
? { ...existing, updatedAt: new Date().toISOString() }
|
|
57
|
+
: defaultProfile(name, mode);
|
|
58
|
+
profile.mode = mode;
|
|
59
|
+
if (opts.spa)
|
|
60
|
+
profile.spaFallback = true;
|
|
61
|
+
if (opts.redirectBase)
|
|
62
|
+
profile.oauthRedirectBase = opts.redirectBase;
|
|
63
|
+
if (opts.customDomain)
|
|
64
|
+
profile.customDomain = opts.customDomain;
|
|
65
|
+
// GitHub
|
|
66
|
+
let clientId = opts.githubClientId || profile.providers.github?.clientId || process.env.CSG_GITHUB_CLIENT_ID;
|
|
67
|
+
let clientSecret = opts.githubClientSecret || process.env.CSG_GITHUB_CLIENT_SECRET;
|
|
68
|
+
if (interactive && !opts.nonInteractive) {
|
|
69
|
+
const enableGh = await confirm({
|
|
70
|
+
message: 'Enable GitHub OAuth?',
|
|
71
|
+
default: true,
|
|
72
|
+
});
|
|
73
|
+
profile.providers.github = {
|
|
74
|
+
enabled: enableGh,
|
|
75
|
+
mode: undefined,
|
|
76
|
+
rules: profile.providers.github?.rules || {},
|
|
77
|
+
};
|
|
78
|
+
if (enableGh) {
|
|
79
|
+
if (!clientId) {
|
|
80
|
+
clientId = await input({
|
|
81
|
+
message: 'GitHub OAuth Client ID',
|
|
82
|
+
validate: (v) => v.trim().length > 0 || 'required',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (!clientSecret) {
|
|
86
|
+
clientSecret = await password({
|
|
87
|
+
message: 'GitHub OAuth Client Secret',
|
|
88
|
+
validate: (v) => v.trim().length > 0 || 'required',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (mode === 'strict') {
|
|
92
|
+
profile.providers.github.rules = await promptStrictRules(profile.providers.github.rules || {});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (profile.providers.github?.enabled !== false) {
|
|
97
|
+
profile.providers.github = {
|
|
98
|
+
...profile.providers.github,
|
|
99
|
+
enabled: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (clientId)
|
|
103
|
+
profile.providers.github = { ...profile.providers.github, enabled: profile.providers.github?.enabled !== false, clientId };
|
|
104
|
+
// KV
|
|
105
|
+
let kvId = opts.kvId || profile.kvNamespaceId;
|
|
106
|
+
if (!kvId && interactive) {
|
|
107
|
+
const doKv = await confirm({
|
|
108
|
+
message: 'Create KV namespace RULES now? (needed for admin/config rules)',
|
|
109
|
+
default: true,
|
|
110
|
+
});
|
|
111
|
+
if (doKv) {
|
|
112
|
+
step('Creating KV namespace…');
|
|
113
|
+
try {
|
|
114
|
+
kvId = await createKvNamespace(name);
|
|
115
|
+
success(`KV created: ${kvId}`);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
error(e instanceof Error ? e.message : String(e));
|
|
119
|
+
const manual = await input({ message: 'Paste KV namespace id (or empty to skip)' });
|
|
120
|
+
kvId = manual.trim() || undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else if (opts.createKv && !kvId) {
|
|
125
|
+
step('Creating KV namespace…');
|
|
126
|
+
kvId = await createKvNamespace(name);
|
|
127
|
+
success(`KV created: ${kvId}`);
|
|
128
|
+
}
|
|
129
|
+
if (kvId)
|
|
130
|
+
profile.kvNamespaceId = kvId;
|
|
131
|
+
// Secrets
|
|
132
|
+
let sessionSecret = opts.sessionSecret || process.env.CSG_SESSION_SECRET;
|
|
133
|
+
let adminPassword = opts.adminPassword || process.env.CSG_ADMIN_PASSWORD;
|
|
134
|
+
if (interactive && !opts.nonInteractive) {
|
|
135
|
+
if (!sessionSecret) {
|
|
136
|
+
const gen = await confirm({
|
|
137
|
+
message: 'Generate SESSION_SECRET automatically?',
|
|
138
|
+
default: true,
|
|
139
|
+
});
|
|
140
|
+
sessionSecret = gen
|
|
141
|
+
? randomBytes(32).toString('base64url')
|
|
142
|
+
: await password({ message: 'SESSION_SECRET' });
|
|
143
|
+
}
|
|
144
|
+
if (!adminPassword) {
|
|
145
|
+
const gen = await confirm({
|
|
146
|
+
message: 'Generate ADMIN_PASSWORD automatically?',
|
|
147
|
+
default: true,
|
|
148
|
+
});
|
|
149
|
+
adminPassword = gen
|
|
150
|
+
? randomBytes(18).toString('base64url')
|
|
151
|
+
: await password({ message: 'ADMIN_PASSWORD (for /admin)' });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
sessionSecret = sessionSecret || randomBytes(32).toString('base64url');
|
|
156
|
+
adminPassword = adminPassword || randomBytes(18).toString('base64url');
|
|
157
|
+
}
|
|
158
|
+
profile.updatedAt = new Date().toISOString();
|
|
159
|
+
upsertProfile(profile, true);
|
|
160
|
+
success(`Profile saved globally: ${profile.name}`);
|
|
161
|
+
// Push secrets to CF (requires existing worker after first deploy, or wrangler will still accept)
|
|
162
|
+
const secrets = [
|
|
163
|
+
['SESSION_SECRET', sessionSecret],
|
|
164
|
+
['ADMIN_PASSWORD', adminPassword],
|
|
165
|
+
['GITHUB_CLIENT_ID', clientId],
|
|
166
|
+
['GITHUB_CLIENT_SECRET', clientSecret],
|
|
167
|
+
];
|
|
168
|
+
const doSecrets = opts.nonInteractive ||
|
|
169
|
+
(await confirm({
|
|
170
|
+
message: `Write secrets to Cloudflare worker "${profile.name}" now?`,
|
|
171
|
+
default: true,
|
|
172
|
+
}));
|
|
173
|
+
if (doSecrets) {
|
|
174
|
+
for (const [key, value] of secrets) {
|
|
175
|
+
if (!value)
|
|
176
|
+
continue;
|
|
177
|
+
step(`secret put ${key}…`);
|
|
178
|
+
try {
|
|
179
|
+
await putSecret(profile.name, key, value);
|
|
180
|
+
success(`${key} set`);
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
error(`${key}: ${e instanceof Error ? e.message : e}`);
|
|
184
|
+
warn('If the worker does not exist yet, deploy once then re-run secrets.');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const doPush = opts.push ||
|
|
189
|
+
(profile.kvNamespaceId &&
|
|
190
|
+
(opts.nonInteractive ||
|
|
191
|
+
(await confirm({
|
|
192
|
+
message: 'Push initial auth/ui config to KV?',
|
|
193
|
+
default: true,
|
|
194
|
+
}))));
|
|
195
|
+
if (doPush && profile.kvNamespaceId) {
|
|
196
|
+
try {
|
|
197
|
+
await pushAuthConfig(profile);
|
|
198
|
+
await pushUiConfig(profile);
|
|
199
|
+
success('KV auth:config + ui:config written');
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
error(e instanceof Error ? e.message : String(e));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else if (!profile.kvNamespaceId) {
|
|
206
|
+
warn('No KV id — rules/config commands and admin save will not work until you add one.');
|
|
207
|
+
}
|
|
208
|
+
box([
|
|
209
|
+
'Next:',
|
|
210
|
+
` csg deploy --site <your-dist>`,
|
|
211
|
+
` csg open admin # password = ADMIN_PASSWORD`,
|
|
212
|
+
'',
|
|
213
|
+
'Nothing was written into your site project directory.',
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
async function promptStrictRules(initial) {
|
|
218
|
+
const rules = { ...initial };
|
|
219
|
+
const allow = await input({
|
|
220
|
+
message: 'Allowlist (comma-separated logins, empty to skip)',
|
|
221
|
+
default: (rules.allowlist || []).join(','),
|
|
222
|
+
});
|
|
223
|
+
rules.allowlist = allow
|
|
224
|
+
.split(',')
|
|
225
|
+
.map((s) => s.trim())
|
|
226
|
+
.filter(Boolean);
|
|
227
|
+
const orgs = await input({
|
|
228
|
+
message: 'Required GitHub orgs (comma-separated, empty to skip)',
|
|
229
|
+
default: (rules.requiredOrgs || []).join(','),
|
|
230
|
+
});
|
|
231
|
+
rules.requiredOrgs = orgs
|
|
232
|
+
.split(',')
|
|
233
|
+
.map((s) => s.trim())
|
|
234
|
+
.filter(Boolean);
|
|
235
|
+
const teams = await input({
|
|
236
|
+
message: 'Required teams org/team (comma-separated, empty to skip)',
|
|
237
|
+
default: (rules.requiredTeams || []).map((t) => `${t.org}/${t.team}`).join(','),
|
|
238
|
+
});
|
|
239
|
+
rules.requiredTeams = teams
|
|
240
|
+
.split(',')
|
|
241
|
+
.map((s) => s.trim())
|
|
242
|
+
.filter(Boolean)
|
|
243
|
+
.map((line) => {
|
|
244
|
+
const [org, team] = line.split('/');
|
|
245
|
+
return { org: org || '', team: team || '' };
|
|
246
|
+
})
|
|
247
|
+
.filter((t) => t.org && t.team);
|
|
248
|
+
const age = await input({
|
|
249
|
+
message: 'Min account age in days (empty to skip)',
|
|
250
|
+
default: rules.minAccountAgeDays != null ? String(rules.minAccountAgeDays) : '',
|
|
251
|
+
});
|
|
252
|
+
if (age.trim())
|
|
253
|
+
rules.minAccountAgeDays = Number(age.trim());
|
|
254
|
+
else
|
|
255
|
+
delete rules.minAccountAgeDays;
|
|
256
|
+
return rules;
|
|
257
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getProfile, loadStore, upsertProfile } from '../lib/store.js';
|
|
2
|
+
import { error, step, success, warn } from '../lib/log.js';
|
|
3
|
+
import { createKvNamespace } from '../lib/cf.js';
|
|
4
|
+
import { pushAuthConfig, pushUiConfig } from '../lib/kv.js';
|
|
5
|
+
export function registerKv(program) {
|
|
6
|
+
const kv = program.command('kv').description('KV namespace helpers for RULES');
|
|
7
|
+
kv
|
|
8
|
+
.command('create')
|
|
9
|
+
.description('Create KV namespace and store id on the profile')
|
|
10
|
+
.option('-p, --profile <name>')
|
|
11
|
+
.option('--title <title>', 'Namespace title')
|
|
12
|
+
.option('--push', 'Push default auth/ui config after create')
|
|
13
|
+
.action(async (opts) => {
|
|
14
|
+
const store = loadStore();
|
|
15
|
+
const profile = getProfile(opts.profile);
|
|
16
|
+
if (!profile) {
|
|
17
|
+
error('No profile. Run `csg init` first.');
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
step('Creating KV namespace RULES…');
|
|
22
|
+
const id = await createKvNamespace(opts.title || profile.name);
|
|
23
|
+
const next = {
|
|
24
|
+
...profile,
|
|
25
|
+
kvNamespaceId: id,
|
|
26
|
+
updatedAt: new Date().toISOString(),
|
|
27
|
+
};
|
|
28
|
+
upsertProfile(next, store.defaultProfile === profile.name);
|
|
29
|
+
success(`KV id saved: ${id}`);
|
|
30
|
+
if (opts.push) {
|
|
31
|
+
await pushAuthConfig(next);
|
|
32
|
+
await pushUiConfig(next);
|
|
33
|
+
success('Pushed default auth/ui config');
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
warn('Run `csg config push` later to write auth:config / ui:config');
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { getProfile } from '../lib/store.js';
|
|
3
|
+
import { error, info } from '../lib/log.js';
|
|
4
|
+
function openUrl(url) {
|
|
5
|
+
if (process.platform === 'win32') {
|
|
6
|
+
void execa('cmd', ['/c', 'start', '', url]);
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
if (process.platform === 'darwin') {
|
|
10
|
+
void execa('open', [url]);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
void execa('xdg-open', [url]);
|
|
14
|
+
}
|
|
15
|
+
export function registerOpen(program) {
|
|
16
|
+
program
|
|
17
|
+
.command('open <target>')
|
|
18
|
+
.description('Open site | admin | login | health in browser')
|
|
19
|
+
.option('-p, --profile <name>')
|
|
20
|
+
.option('--base <url>', 'Full origin, e.g. https://my-docs.xxx.workers.dev')
|
|
21
|
+
.action(async (target, opts) => {
|
|
22
|
+
const profile = getProfile(opts.profile);
|
|
23
|
+
let base = opts.base || process.env.CSG_BASE_URL;
|
|
24
|
+
if (!base) {
|
|
25
|
+
if (!profile) {
|
|
26
|
+
error('No profile and no --base. Pass --base https://<worker>.<subdomain>.workers.dev');
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// users often have custom domains; default guess is name.workers.dev under unknown subdomain
|
|
31
|
+
error(`Pass --base https://your-worker-domain (profile ${profile.name} has no recorded public origin).`);
|
|
32
|
+
info('Example: csg open admin --base https://my-docs.acme.workers.dev');
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
base = base.replace(/\/$/, '');
|
|
37
|
+
const map = {
|
|
38
|
+
site: '/',
|
|
39
|
+
home: '/',
|
|
40
|
+
admin: '/admin',
|
|
41
|
+
login: '/auth/login',
|
|
42
|
+
health: '/health',
|
|
43
|
+
};
|
|
44
|
+
const path = map[target];
|
|
45
|
+
if (!path) {
|
|
46
|
+
error(`Unknown target ${target}. Use: site | admin | login | health`);
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const url = base + path;
|
|
51
|
+
info(`Opening ${url}`);
|
|
52
|
+
openUrl(url);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { listProfileNames, loadStore, setDefaultProfile, upsertProfile, } from '../lib/store.js';
|
|
2
|
+
import { error, info, success } from '../lib/log.js';
|
|
3
|
+
export function registerProfile(program) {
|
|
4
|
+
const profile = program
|
|
5
|
+
.command('profile')
|
|
6
|
+
.description('Manage global deploy profiles (not stored in your site repo)');
|
|
7
|
+
profile
|
|
8
|
+
.command('list')
|
|
9
|
+
.description('List profiles')
|
|
10
|
+
.action(() => {
|
|
11
|
+
const store = loadStore();
|
|
12
|
+
const names = listProfileNames();
|
|
13
|
+
if (!names.length) {
|
|
14
|
+
info('No profiles. Run `csg init`.');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
const p = store.profiles[name];
|
|
19
|
+
const mark = name === store.defaultProfile ? '*' : ' ';
|
|
20
|
+
console.log(`${mark} ${name} worker=${p.name} mode=${p.mode} kv=${p.kvNamespaceId || '-'}`);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
profile
|
|
24
|
+
.command('use <name>')
|
|
25
|
+
.description('Set default profile')
|
|
26
|
+
.action((name) => {
|
|
27
|
+
if (!setDefaultProfile(name)) {
|
|
28
|
+
error(`Profile not found: ${name}`);
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
success(`Default profile → ${name}`);
|
|
33
|
+
});
|
|
34
|
+
profile
|
|
35
|
+
.command('show [name]')
|
|
36
|
+
.description('Show one profile JSON')
|
|
37
|
+
.action((name) => {
|
|
38
|
+
const store = loadStore();
|
|
39
|
+
const key = name || store.defaultProfile;
|
|
40
|
+
const p = store.profiles[key];
|
|
41
|
+
if (!p) {
|
|
42
|
+
error(`Profile not found: ${key}`);
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
console.log(JSON.stringify(p, null, 2));
|
|
47
|
+
});
|
|
48
|
+
profile
|
|
49
|
+
.command('set-kv <id>')
|
|
50
|
+
.description('Set KV namespace id on current/default profile')
|
|
51
|
+
.option('-p, --profile <name>', 'profile name')
|
|
52
|
+
.action((id, opts) => {
|
|
53
|
+
const store = loadStore();
|
|
54
|
+
const key = opts.profile || store.defaultProfile;
|
|
55
|
+
const p = store.profiles[key];
|
|
56
|
+
if (!p) {
|
|
57
|
+
error(`Profile not found: ${key}`);
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const next = {
|
|
62
|
+
...p,
|
|
63
|
+
kvNamespaceId: id,
|
|
64
|
+
updatedAt: new Date().toISOString(),
|
|
65
|
+
};
|
|
66
|
+
upsertProfile(next, key === store.defaultProfile);
|
|
67
|
+
success(`KV namespace id saved on profile ${key}`);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { password } from '@inquirer/prompts';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { getProfile } from '../lib/store.js';
|
|
4
|
+
import { error, step, success } from '../lib/log.js';
|
|
5
|
+
import { putSecret } from '../lib/cf.js';
|
|
6
|
+
const KEYS = [
|
|
7
|
+
'GITHUB_CLIENT_ID',
|
|
8
|
+
'GITHUB_CLIENT_SECRET',
|
|
9
|
+
'SESSION_SECRET',
|
|
10
|
+
'ADMIN_PASSWORD',
|
|
11
|
+
];
|
|
12
|
+
export function registerSecrets(program) {
|
|
13
|
+
const secrets = program
|
|
14
|
+
.command('secrets')
|
|
15
|
+
.description('Set Cloudflare Worker secrets (not stored in your project)');
|
|
16
|
+
secrets
|
|
17
|
+
.command('set [name]')
|
|
18
|
+
.description('Set one secret, or run without name for interactive multi-set')
|
|
19
|
+
.option('-p, --profile <name>', 'worker profile')
|
|
20
|
+
.option('--value <v>', 'Secret value (prefer prompt/env for sensitive data)')
|
|
21
|
+
.option('--generate', 'Generate a random value (SESSION_SECRET / ADMIN_PASSWORD)')
|
|
22
|
+
.action(async (name, opts) => {
|
|
23
|
+
const profile = getProfile(opts.profile);
|
|
24
|
+
if (!profile) {
|
|
25
|
+
error('No profile. Run `csg init` first.');
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const targets = name ? [name] : [...KEYS];
|
|
30
|
+
if (name && !KEYS.includes(name)) {
|
|
31
|
+
error(`Unknown secret. Use: ${KEYS.join(', ')}`);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const key of targets) {
|
|
36
|
+
let value = opts.value;
|
|
37
|
+
if (!value && opts.generate && (key === 'SESSION_SECRET' || key === 'ADMIN_PASSWORD')) {
|
|
38
|
+
value = randomBytes(24).toString('base64url');
|
|
39
|
+
}
|
|
40
|
+
if (!value) {
|
|
41
|
+
value = await password({
|
|
42
|
+
message: `${key} value for worker ${profile.name}`,
|
|
43
|
+
mask: '*',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (!value) {
|
|
47
|
+
error(`${key}: empty value`);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
step(`secret put ${key}…`);
|
|
52
|
+
await putSecret(profile.name, key, value);
|
|
53
|
+
success(`${key} updated on ${profile.name}`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
secrets
|
|
57
|
+
.command('rotate-session')
|
|
58
|
+
.description('Generate and set a new SESSION_SECRET (invalidates all logins)')
|
|
59
|
+
.option('-p, --profile <name>')
|
|
60
|
+
.action(async (opts) => {
|
|
61
|
+
const profile = getProfile(opts.profile);
|
|
62
|
+
if (!profile) {
|
|
63
|
+
error('No profile. Run `csg init` first.');
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const value = randomBytes(32).toString('base64url');
|
|
68
|
+
step('secret put SESSION_SECRET…');
|
|
69
|
+
await putSecret(profile.name, 'SESSION_SECRET', value);
|
|
70
|
+
success('SESSION_SECRET rotated — users must log in again');
|
|
71
|
+
});
|
|
72
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { cliVersion } from './lib/paths.js';
|
|
3
|
+
import { registerAuth } from './commands/auth.js';
|
|
4
|
+
import { registerInit } from './commands/init.js';
|
|
5
|
+
import { registerDeploy } from './commands/deploy.js';
|
|
6
|
+
import { registerConfig } from './commands/config.js';
|
|
7
|
+
import { registerSecrets } from './commands/secrets.js';
|
|
8
|
+
import { registerOpen } from './commands/open.js';
|
|
9
|
+
import { registerDoctor } from './commands/doctor.js';
|
|
10
|
+
import { registerProfile } from './commands/profile.js';
|
|
11
|
+
import { registerKv } from './commands/kv.js';
|
|
12
|
+
export async function run(argv) {
|
|
13
|
+
const program = new Command();
|
|
14
|
+
program
|
|
15
|
+
.name('csg')
|
|
16
|
+
.description('Cloudflare static-site guard CLI — deploy any static site behind OAuth without cloning the monorepo.')
|
|
17
|
+
.version(cliVersion());
|
|
18
|
+
registerAuth(program);
|
|
19
|
+
registerInit(program);
|
|
20
|
+
registerDeploy(program);
|
|
21
|
+
registerConfig(program);
|
|
22
|
+
registerSecrets(program);
|
|
23
|
+
registerOpen(program);
|
|
24
|
+
registerDoctor(program);
|
|
25
|
+
registerProfile(program);
|
|
26
|
+
registerKv(program);
|
|
27
|
+
program.addHelpText('after', `
|
|
28
|
+
Examples:
|
|
29
|
+
$ csg auth
|
|
30
|
+
$ csg init --name my-docs --mode normal
|
|
31
|
+
$ csg deploy --site ./dist
|
|
32
|
+
$ csg config get
|
|
33
|
+
$ csg config set-mode strict
|
|
34
|
+
$ csg open admin
|
|
35
|
+
|
|
36
|
+
Config lives in your global csg folder (~/.config/csg or %APPDATA%/csg).
|
|
37
|
+
Secrets and rules live on Cloudflare. This CLI does not write into your site repo.
|
|
38
|
+
`);
|
|
39
|
+
await program.parseAsync(argv);
|
|
40
|
+
}
|
package/dist/lib/cf.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { runWrangler } from './wrangler.js';
|
|
2
|
+
export async function putSecret(workerName, name, value) {
|
|
3
|
+
const res = await runWrangler(['secret', 'put', name, '--name', workerName], { input: value });
|
|
4
|
+
if (res.exitCode !== 0) {
|
|
5
|
+
throw new Error(res.stderr || res.stdout || `secret put ${name} failed`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export async function createKvNamespace(_title) {
|
|
9
|
+
const res = await runWrangler(['kv', 'namespace', 'create', 'RULES', '--preview=false']);
|
|
10
|
+
// wrangler prints: { binding = "RULES", id = "xxxx" }
|
|
11
|
+
const text = `${res.stdout}\n${res.stderr}`;
|
|
12
|
+
const match = text.match(/id\s*=\s*"([a-f0-9]{32})"/i) || text.match(/id['":\s]+([a-f0-9]{32})/i);
|
|
13
|
+
if (res.exitCode !== 0 || !match) {
|
|
14
|
+
throw new Error(`Failed to create KV namespace.\n${text}\nCreate it in the dashboard and set kvNamespaceId on the profile.`);
|
|
15
|
+
}
|
|
16
|
+
return match[1];
|
|
17
|
+
}
|
|
18
|
+
export async function whoami() {
|
|
19
|
+
const res = await runWrangler(['whoami']);
|
|
20
|
+
const text = `${res.stdout}\n${res.stderr}`.trim();
|
|
21
|
+
return { ok: res.exitCode === 0, text };
|
|
22
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { workerBundlePath } from './paths.js';
|
|
5
|
+
export function buildWranglerToml(profile) {
|
|
6
|
+
const publicPaths = (profile.publicPaths?.length
|
|
7
|
+
? profile.publicPaths
|
|
8
|
+
: ['/health'])
|
|
9
|
+
.map((p) => (p.startsWith('/') ? p : `/${p}`))
|
|
10
|
+
.join(',');
|
|
11
|
+
const kvBlock = profile.kvNamespaceId
|
|
12
|
+
? `
|
|
13
|
+
[[kv_namespaces]]
|
|
14
|
+
binding = "RULES"
|
|
15
|
+
id = "${profile.kvNamespaceId}"
|
|
16
|
+
`
|
|
17
|
+
: `
|
|
18
|
+
# KV not configured. Run: csg kv create
|
|
19
|
+
# [[kv_namespaces]]
|
|
20
|
+
# binding = "RULES"
|
|
21
|
+
# id = "<id>"
|
|
22
|
+
`;
|
|
23
|
+
const redirect = profile.oauthRedirectBase
|
|
24
|
+
? `\nOAUTH_REDIRECT_BASE = "${profile.oauthRedirectBase}"`
|
|
25
|
+
: '';
|
|
26
|
+
const customDomain = profile.customDomain
|
|
27
|
+
? `
|
|
28
|
+
[[routes]]
|
|
29
|
+
pattern = "${profile.customDomain}"
|
|
30
|
+
custom_domain = true
|
|
31
|
+
`
|
|
32
|
+
: '';
|
|
33
|
+
return `name = "${profile.name}"
|
|
34
|
+
main = "worker.js"
|
|
35
|
+
compatibility_date = "2025-03-01"
|
|
36
|
+
compatibility_flags = ["nodejs_compat"]
|
|
37
|
+
${customDomain}
|
|
38
|
+
[assets]
|
|
39
|
+
directory = "./assets/site"
|
|
40
|
+
binding = "ASSETS"
|
|
41
|
+
run_worker_first = true
|
|
42
|
+
|
|
43
|
+
[vars]
|
|
44
|
+
GUARD_MODE = "${profile.mode}"
|
|
45
|
+
PUBLIC_PATHS = "${publicPaths}"
|
|
46
|
+
SPA_FALLBACK = "${profile.spaFallback ? 'true' : 'false'}"
|
|
47
|
+
SESSION_TTL_SECONDS = "${String(profile.sessionTtlSeconds || 604800)}"
|
|
48
|
+
${redirect}
|
|
49
|
+
${kvBlock}`;
|
|
50
|
+
}
|
|
51
|
+
export function prepareDeployDir(profile, siteDir) {
|
|
52
|
+
if (!existsSync(workerBundlePath())) {
|
|
53
|
+
throw new Error('Worker bundle missing. Run `npm run build` in the monorepo, or reinstall the CLI package.');
|
|
54
|
+
}
|
|
55
|
+
if (!existsSync(siteDir)) {
|
|
56
|
+
throw new Error(`Site directory not found: ${siteDir}`);
|
|
57
|
+
}
|
|
58
|
+
const root = mkdtempSync(join(tmpdir(), 'csg-deploy-'));
|
|
59
|
+
const assets = join(root, 'assets', 'site');
|
|
60
|
+
mkdirSync(assets, { recursive: true });
|
|
61
|
+
cpSync(workerBundlePath(), join(root, 'worker.js'));
|
|
62
|
+
cpSync(siteDir, assets, { recursive: true });
|
|
63
|
+
writeFileSync(join(root, 'wrangler.toml'), buildWranglerToml(profile), 'utf8');
|
|
64
|
+
return {
|
|
65
|
+
root,
|
|
66
|
+
cleanup: () => {
|
|
67
|
+
try {
|
|
68
|
+
rmSync(root, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// ignore
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|