@ibalzam/codejitsu-core 0.6.0 → 0.8.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/bin/codejitsu.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
3
  import { runBlog } from '../modules/cli/src/blog.mjs';
4
+ import { runDeploySetup, runDeployTrigger } from '../modules/cli/src/deploy.mjs';
4
5
  import { runAudit } from '../modules/audit/src/run.mjs';
5
6
 
6
7
  const subcommand = process.argv[2];
@@ -9,6 +10,8 @@ const rest = process.argv.slice(3);
9
10
  const COMMANDS = {
10
11
  'blog:list': () => runBlog('blog:list'),
11
12
  'blog:drafts': () => runBlog('blog:drafts'),
13
+ 'deploy:setup': () => runDeploySetup(),
14
+ 'deploy:run': () => runDeployTrigger(),
12
15
  audit: () => {
13
16
  const { values } = parseArgs({
14
17
  args: rest,
@@ -52,6 +55,9 @@ function printHelp() {
52
55
  console.log(` blog:list List every non-draft post with URL + image check`);
53
56
  console.log(` blog:drafts List future-dated (pending) posts only`);
54
57
  console.log(``);
58
+ console.log(` deploy:setup Wire up daily Cloudflare deploy (prompts for hook URL)`);
59
+ console.log(` deploy:run Trigger the Daily Deploy workflow once now`);
60
+ console.log(``);
55
61
  console.log(` audit Run pre-delivery audit. Flags:`);
56
62
  console.log(` --live <url> Add live-URL checks (SSL, headers, 404, broken links)`);
57
63
  console.log(` --a11y Add axe-core WCAG 2.1 AA scan (with --live)`);
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { spawn } from 'child_process';
3
4
  import { pass, fail, warn, info } from '../util.mjs';
4
5
 
5
6
  export async function runStructure(ctx) {
@@ -51,11 +52,40 @@ export async function runStructure(ctx) {
51
52
  ? pass('wrangler.toml at site root')
52
53
  : fail('wrangler.toml missing')
53
54
  );
55
+ const workflowExists = fs.existsSync(path.join(cwd, '.github/workflows/daily-deploy.yml'));
54
56
  results.push(
55
- fs.existsSync(path.join(cwd, '.github/workflows/daily-deploy.yml'))
57
+ workflowExists
56
58
  ? pass('Daily deploy workflow present')
57
59
  : warn('No .github/workflows/daily-deploy.yml')
58
60
  );
61
+
62
+ // Daily deploy needs CLOUDFLARE_DEPLOY_HOOK_URL set as a GH secret.
63
+ // We can only verify this via `gh secret list`. Skip silently if gh is
64
+ // unavailable; only run when the workflow file exists (otherwise the
65
+ // secret is moot).
66
+ if (workflowExists) {
67
+ const repo = detectGitHubRepo(cwd);
68
+ if (!repo) {
69
+ results.push(info('Skipped GH secret check (no GitHub remote detected)'));
70
+ } else {
71
+ const ghAvailable = await commandExists('gh');
72
+ if (!ghAvailable) {
73
+ results.push(info('Skipped GH secret check (gh CLI not installed)'));
74
+ } else {
75
+ const secrets = await listSecrets(repo);
76
+ if (secrets === null) {
77
+ results.push(info('Skipped GH secret check (gh not authenticated for this repo)'));
78
+ } else if (secrets.includes('CLOUDFLARE_DEPLOY_HOOK_URL')) {
79
+ results.push(pass(`CLOUDFLARE_DEPLOY_HOOK_URL set in ${repo}`));
80
+ } else {
81
+ results.push(warn(
82
+ 'CLOUDFLARE_DEPLOY_HOOK_URL secret missing',
83
+ 'Daily deploy workflow will fail. Run `npx codejitsu deploy:setup` to configure.'
84
+ ));
85
+ }
86
+ }
87
+ }
88
+ }
59
89
  }
60
90
 
61
91
  // Astro config sanity + trailing-slash plugin agreement
@@ -103,3 +133,32 @@ export async function runStructure(ctx) {
103
133
 
104
134
  return results;
105
135
  }
136
+
137
+ function detectGitHubRepo(cwd) {
138
+ const cfgPath = path.join(cwd, '.git/config');
139
+ if (!fs.existsSync(cfgPath)) return null;
140
+ const cfg = fs.readFileSync(cfgPath, 'utf8');
141
+ const m = cfg.match(/github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/m);
142
+ return m ? m[1] : null;
143
+ }
144
+
145
+ function commandExists(cmd) {
146
+ return new Promise((resolve) => {
147
+ const proc = spawn('which', [cmd], { stdio: 'ignore' });
148
+ proc.on('close', (code) => resolve(code === 0));
149
+ proc.on('error', () => resolve(false));
150
+ });
151
+ }
152
+
153
+ function listSecrets(repo) {
154
+ return new Promise((resolve) => {
155
+ const proc = spawn('gh', ['secret', 'list', '--repo', repo], { stdio: ['ignore', 'pipe', 'pipe'] });
156
+ let stdout = '';
157
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
158
+ proc.on('close', (code) => {
159
+ if (code !== 0) return resolve(null);
160
+ resolve(stdout.split('\n').map((l) => l.split(/\s+/)[0]).filter(Boolean));
161
+ });
162
+ proc.on('error', () => resolve(null));
163
+ });
164
+ }
@@ -0,0 +1,259 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import readline from 'readline';
4
+ import { spawn } from 'child_process';
5
+ import { loadConfig, isModuleEnabled } from '../../config/src/load.mjs';
6
+ import { c } from './format.mjs';
7
+
8
+ const PACKAGE_ROOT = path.resolve(
9
+ path.dirname(new URL(import.meta.url).pathname),
10
+ '..', '..', '..'
11
+ );
12
+
13
+ /**
14
+ * `codejitsu deploy:setup` — interactive wizard for Cloudflare Pages + daily
15
+ * deploy hook. Copies the workflow + wrangler templates, prompts for the
16
+ * Cloudflare deploy hook URL, and stores it as a GitHub Actions secret.
17
+ *
18
+ * Idempotent: re-running with everything already in place is a no-op (just
19
+ * verifies state).
20
+ */
21
+ export async function runDeploySetup() {
22
+ const cwd = process.cwd();
23
+
24
+ let config;
25
+ try { config = await loadConfig(cwd); }
26
+ catch (err) {
27
+ console.error(c.red('✗ No codejitsu.config found in ' + cwd));
28
+ process.exit(1);
29
+ }
30
+
31
+ if (!isModuleEnabled(config, 'deploy')) {
32
+ console.error(c.red('✗ deploy module is disabled in codejitsu.config'));
33
+ process.exit(1);
34
+ }
35
+
36
+ console.log(c.bold('\nCodejitsu Deploy Setup\n'));
37
+
38
+ // ─── Detect environment ──────────────────────────────────────────────
39
+ if (!fs.existsSync(path.join(cwd, '.git'))) {
40
+ console.error(c.red('✗ Not a git repository. Initialise git first.'));
41
+ process.exit(1);
42
+ }
43
+
44
+ const repo = detectGitHubRepo(cwd);
45
+ if (!repo) {
46
+ console.error(c.red('✗ Could not detect GitHub repo from git remote.'));
47
+ console.error(' Set up: git remote add origin git@github.com:owner/name.git');
48
+ process.exit(1);
49
+ }
50
+ console.log(c.green('✓') + ` GitHub repo: ${c.bold(repo)}`);
51
+
52
+ const ghAvailable = await commandExists('gh');
53
+ if (!ghAvailable) {
54
+ console.error(c.red('✗ `gh` CLI not in PATH.'));
55
+ console.error(' Install: https://cli.github.com/');
56
+ process.exit(1);
57
+ }
58
+ const ghAuthed = await ghIsAuthed();
59
+ if (!ghAuthed) {
60
+ console.error(c.red('✗ `gh` not authenticated.'));
61
+ console.error(' Run: gh auth login');
62
+ process.exit(1);
63
+ }
64
+ console.log(c.green('✓') + ' gh CLI authenticated');
65
+
66
+ const pagesName = config.deploy?.cloudflarePagesName;
67
+ if (pagesName) console.log(c.green('✓') + ` Cloudflare Pages name: ${c.bold(pagesName)}`);
68
+ else console.log(c.yellow('!') + ' No deploy.cloudflarePagesName in codejitsu.config (wrangler.toml will need a manual name)');
69
+
70
+ // ─── Workflow + wrangler files ───────────────────────────────────────
71
+ console.log('\nChecking files…');
72
+
73
+ const workflowDest = path.join(cwd, '.github/workflows/daily-deploy.yml');
74
+ const wranglerDest = path.join(cwd, 'wrangler.toml');
75
+
76
+ if (fs.existsSync(workflowDest)) {
77
+ console.log(c.green('✓') + ' .github/workflows/daily-deploy.yml exists');
78
+ } else {
79
+ const workflowSrc = path.join(PACKAGE_ROOT, 'modules/deploy/templates/daily-deploy.yml');
80
+ fs.mkdirSync(path.dirname(workflowDest), { recursive: true });
81
+ fs.copyFileSync(workflowSrc, workflowDest);
82
+ console.log(c.green('+') + ' Created .github/workflows/daily-deploy.yml');
83
+ }
84
+
85
+ if (fs.existsSync(wranglerDest)) {
86
+ console.log(c.green('✓') + ' wrangler.toml exists');
87
+ } else {
88
+ const wranglerSrc = path.join(PACKAGE_ROOT, 'modules/deploy/templates/wrangler.toml');
89
+ let contents = fs.readFileSync(wranglerSrc, 'utf8');
90
+ if (pagesName) contents = contents.replace('TODO-site-name', pagesName);
91
+ fs.writeFileSync(wranglerDest, contents);
92
+ console.log(c.green('+') + ' Created wrangler.toml' + (pagesName ? '' : ' (edit `name` field)'));
93
+ }
94
+
95
+ // ─── GitHub secret ───────────────────────────────────────────────────
96
+ console.log('\nChecking GitHub Actions secrets…');
97
+ const secrets = await listSecrets(repo);
98
+ const hasSecret = secrets.includes('CLOUDFLARE_DEPLOY_HOOK_URL');
99
+
100
+ if (hasSecret) {
101
+ console.log(c.green('✓') + ' CLOUDFLARE_DEPLOY_HOOK_URL is set');
102
+ const rotate = await prompt('\nRotate the deploy hook URL? [y/N]: ');
103
+ if (!/^y(es)?$/i.test(rotate.trim())) {
104
+ console.log(c.gray('\nNothing to do. The daily deploy is configured.\n'));
105
+ await offerTestRun(repo);
106
+ return;
107
+ }
108
+ }
109
+
110
+ // Prompt for URL.
111
+ console.log('');
112
+ console.log('Get a deploy hook URL from Cloudflare Pages:');
113
+ console.log(c.gray(' 1. Open Cloudflare dashboard → Pages → ' + (pagesName ?? 'your project') + ' → Settings'));
114
+ console.log(c.gray(' 2. Builds & deployments → Deploy hooks → "Add deploy hook"'));
115
+ console.log(c.gray(' 3. Name: "daily-scheduled-content" — Branch: main'));
116
+ console.log(c.gray(' 4. Copy the URL'));
117
+ console.log('');
118
+
119
+ const url = (await prompt('Paste the deploy hook URL: ')).trim();
120
+ if (!url.startsWith('https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/')) {
121
+ console.error(c.red('\n✗ That doesn\'t look like a Cloudflare Pages deploy hook URL.'));
122
+ console.error(' Expected prefix: https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/');
123
+ process.exit(1);
124
+ }
125
+
126
+ const setOk = await setSecret(repo, 'CLOUDFLARE_DEPLOY_HOOK_URL', url);
127
+ if (!setOk) {
128
+ console.error(c.red('\n✗ Failed to set GitHub secret.'));
129
+ process.exit(1);
130
+ }
131
+ console.log(c.green('✓') + ` Secret CLOUDFLARE_DEPLOY_HOOK_URL set in ${repo}`);
132
+
133
+ await offerTestRun(repo);
134
+ }
135
+
136
+ /**
137
+ * `codejitsu deploy:run` — fire the "Daily Deploy" workflow once on demand.
138
+ * Useful when you want to publish a scheduled blog post immediately instead
139
+ * of waiting for the next 13:00 UTC cron tick.
140
+ */
141
+ export async function runDeployTrigger() {
142
+ const cwd = process.cwd();
143
+
144
+ const repo = detectGitHubRepo(cwd);
145
+ if (!repo) {
146
+ console.error(c.red('✗ Could not detect GitHub repo from git remote.'));
147
+ process.exit(1);
148
+ }
149
+
150
+ const ghAvailable = await commandExists('gh');
151
+ if (!ghAvailable) {
152
+ console.error(c.red('✗ `gh` CLI not in PATH. Install: https://cli.github.com/'));
153
+ process.exit(1);
154
+ }
155
+ const ghAuthed = await ghIsAuthed();
156
+ if (!ghAuthed) {
157
+ console.error(c.red('✗ `gh` not authenticated. Run: gh auth login'));
158
+ process.exit(1);
159
+ }
160
+
161
+ console.log(`Triggering "Daily Deploy" in ${c.bold(repo)}…`);
162
+ const trigger = await runGh(['workflow', 'run', 'Daily Deploy', '--repo', repo]);
163
+ if (trigger.code !== 0) {
164
+ console.error(c.red('✗ Failed to trigger workflow.'));
165
+ console.error(' ' + trigger.stderr.trim());
166
+ console.error(' Make sure .github/workflows/daily-deploy.yml exists and is committed.');
167
+ process.exit(1);
168
+ }
169
+ console.log(c.green('✓') + ' Workflow dispatched.');
170
+ console.log(c.gray(` Watch: https://github.com/${repo}/actions/workflows/daily-deploy.yml`));
171
+
172
+ // Brief: show the most recent runs.
173
+ console.log('\nRecent runs:');
174
+ const list = await runGh([
175
+ 'run', 'list',
176
+ '--workflow', 'daily-deploy.yml',
177
+ '--repo', repo,
178
+ '--limit', '3',
179
+ ]);
180
+ if (list.code === 0 && list.stdout.trim()) {
181
+ console.log(c.gray(list.stdout.trim().split('\n').map((l) => ' ' + l).join('\n')));
182
+ }
183
+ }
184
+
185
+ async function offerTestRun(repo) {
186
+ const test = await prompt('\nTrigger the workflow once now to test? [y/N]: ');
187
+ if (!/^y(es)?$/i.test(test.trim())) {
188
+ console.log(c.gray('\nDone. Daily deploy fires at 13:00 UTC (06:00 PDT / 05:00 PST).\n'));
189
+ return;
190
+ }
191
+ const result = await runGh(['workflow', 'run', 'Daily Deploy', '--repo', repo]);
192
+ if (result.code === 0) {
193
+ console.log(c.green('✓') + ' Workflow "Daily Deploy" triggered');
194
+ console.log(c.gray(' Watch: ') + c.gray(`https://github.com/${repo}/actions`));
195
+ } else {
196
+ console.error(c.yellow('!') + ' Could not trigger workflow: ' + result.stderr.trim());
197
+ console.error(' You can trigger it manually from the GitHub Actions tab.');
198
+ }
199
+ }
200
+
201
+ // ─── Helpers ─────────────────────────────────────────────────────────
202
+
203
+ function detectGitHubRepo(cwd) {
204
+ const cfgPath = path.join(cwd, '.git/config');
205
+ if (!fs.existsSync(cfgPath)) return null;
206
+ const cfg = fs.readFileSync(cfgPath, 'utf8');
207
+ // Match SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
208
+ const m = cfg.match(/github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/m);
209
+ return m ? m[1] : null;
210
+ }
211
+
212
+ function commandExists(cmd) {
213
+ return new Promise((resolve) => {
214
+ const proc = spawn('which', [cmd], { stdio: 'ignore' });
215
+ proc.on('close', (code) => resolve(code === 0));
216
+ proc.on('error', () => resolve(false));
217
+ });
218
+ }
219
+
220
+ async function ghIsAuthed() {
221
+ const r = await runGh(['auth', 'status']);
222
+ return r.code === 0;
223
+ }
224
+
225
+ async function listSecrets(repo) {
226
+ const r = await runGh(['secret', 'list', '--repo', repo]);
227
+ if (r.code !== 0) return [];
228
+ return r.stdout
229
+ .split('\n')
230
+ .map((line) => line.split(/\s+/)[0])
231
+ .filter(Boolean);
232
+ }
233
+
234
+ async function setSecret(repo, name, value) {
235
+ const r = await runGh(['secret', 'set', name, '--repo', repo, '--body', value]);
236
+ return r.code === 0;
237
+ }
238
+
239
+ function runGh(args) {
240
+ return new Promise((resolve) => {
241
+ const proc = spawn('gh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
242
+ let stdout = '';
243
+ let stderr = '';
244
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
245
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
246
+ proc.on('close', (code) => resolve({ code, stdout, stderr }));
247
+ proc.on('error', (err) => resolve({ code: 1, stdout: '', stderr: err.message }));
248
+ });
249
+ }
250
+
251
+ function prompt(question) {
252
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
253
+ return new Promise((resolve) => {
254
+ rl.question(question, (answer) => {
255
+ rl.close();
256
+ resolve(answer);
257
+ });
258
+ });
259
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibalzam/codejitsu-core",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Shared core for Codejitsu Astro sites — reusable code and Claude-facing instructions for blog, SEO, images, deploy, and llms.txt.",
6
6
  "keywords": [