@janga/norna 0.7.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.
Files changed (77) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +109 -0
  3. package/astro.config.mjs +17 -0
  4. package/bin/norna.mjs +170 -0
  5. package/docs/README.md +48 -0
  6. package/docs/command-organization.md +402 -0
  7. package/docs/commands.md +152 -0
  8. package/docs/configuration.md +376 -0
  9. package/docs/content.md +384 -0
  10. package/docs/engine-development.md +164 -0
  11. package/docs/getting-started.md +96 -0
  12. package/docs/images-and-metadata.md +88 -0
  13. package/docs/local-development.md +61 -0
  14. package/docs/publishing.md +81 -0
  15. package/docs/site-examples-structure-note.md +105 -0
  16. package/docs/site-structure.md +81 -0
  17. package/fixtures/basic/site/.norna/generated-images.json +1 -0
  18. package/fixtures/basic/site/config.mjs +59 -0
  19. package/fixtures/basic/site/content.md +17 -0
  20. package/fixtures/basic/site/images/work/.gitkeep +1 -0
  21. package/fixtures/basic/site/public/robots.txt +2 -0
  22. package/fixtures/basic/site/theme.md +7 -0
  23. package/package.json +90 -0
  24. package/scripts/build-site.mjs +16 -0
  25. package/scripts/check-config.mjs +37 -0
  26. package/scripts/deploy-site.mjs +389 -0
  27. package/scripts/dev-local.mjs +313 -0
  28. package/scripts/doctor.mjs +38 -0
  29. package/scripts/engine-version.mjs +137 -0
  30. package/scripts/generate-images.mjs +369 -0
  31. package/scripts/init-site.mjs +249 -0
  32. package/scripts/lib/astro-command.mjs +34 -0
  33. package/scripts/lib/ci-lockfile.mjs +34 -0
  34. package/scripts/lib/image-dimensions.mjs +78 -0
  35. package/scripts/lib/presentation.mjs +72 -0
  36. package/scripts/lib/project-config.mjs +322 -0
  37. package/scripts/lib/run-command.mjs +21 -0
  38. package/scripts/lib/site-content.mjs +392 -0
  39. package/scripts/lib/site-paths.mjs +127 -0
  40. package/scripts/lib/typography.mjs +166 -0
  41. package/scripts/release.mjs +77 -0
  42. package/scripts/show-typography.mjs +210 -0
  43. package/scripts/sync-content-sections.mjs +610 -0
  44. package/scripts/sync-site-public.mjs +42 -0
  45. package/scripts/test-ci-lockfile.mjs +65 -0
  46. package/scripts/test-content-check.mjs +364 -0
  47. package/scripts/test-engine-commands.mjs +128 -0
  48. package/scripts/test-navigation-preview.mjs +108 -0
  49. package/scripts/test-navigation.mjs +116 -0
  50. package/scripts/test-package-check.mjs +394 -0
  51. package/scripts/test-site-public.mjs +85 -0
  52. package/scripts/test-temporary-visibility.mjs +99 -0
  53. package/scripts/update-engine.mjs +127 -0
  54. package/scripts/watch-pages-deploy.mjs +430 -0
  55. package/src/components/GalleryGrid.astro +221 -0
  56. package/src/components/SiteNavigation.astro +410 -0
  57. package/src/components/SitePage.astro +69 -0
  58. package/src/components/SiteSection.astro +174 -0
  59. package/src/content.config.ts +171 -0
  60. package/src/layouts/BaseLayout.astro +90 -0
  61. package/src/lib/generatedImages.ts +63 -0
  62. package/src/lib/sectionContent.ts +125 -0
  63. package/src/lib/sitePages.ts +80 -0
  64. package/src/lib/sitePublicAssets.ts +39 -0
  65. package/src/lib/visibility.ts +35 -0
  66. package/src/pages/[slug].astro +31 -0
  67. package/src/pages/index.astro +16 -0
  68. package/src/styles/global.css +872 -0
  69. package/starters/basic/.github/workflows/deploy.yml +65 -0
  70. package/starters/basic/README.md +55 -0
  71. package/starters/basic/package.json +35 -0
  72. package/starters/basic/site/config.mjs +60 -0
  73. package/starters/basic/site/content.md +21 -0
  74. package/starters/basic/site/images/work/.gitkeep +1 -0
  75. package/starters/basic/site/public/robots.txt +2 -0
  76. package/starters/basic/site/theme.md +53 -0
  77. package/tsconfig.json +5 -0
@@ -0,0 +1,389 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { promisify } from 'node:util';
4
+ import {
5
+ getFrontmatterSections,
6
+ getContentFiles,
7
+ readSiteFile,
8
+ supportedImageExtensions,
9
+ toPosixPath,
10
+ } from './lib/site-content.mjs';
11
+ import { projectConfig } from './lib/project-config.mjs';
12
+ import {
13
+ engineRoot,
14
+ generatedImagesManifestLabel,
15
+ siteProjectRoot,
16
+ siteConfigLabel,
17
+ siteContentLabel,
18
+ siteImagesLabel,
19
+ sitePublicLabel,
20
+ siteRoutesLabel,
21
+ siteThemeLabel,
22
+ } from './lib/site-paths.mjs';
23
+
24
+ const execFileAsync = promisify(execFile);
25
+
26
+ const root = siteProjectRoot;
27
+ const branch = projectConfig.github.branch;
28
+ const repo = projectConfig.github.repo;
29
+ const pagesWorkflow = projectConfig.github.pagesWorkflow;
30
+ const args = process.argv.slice(2);
31
+ const mode = args[0] === 'commit' ? 'commit' : 'deploy';
32
+ const modeArgs = mode === 'commit' ? args.slice(1) : args;
33
+ const allowedExactPaths = new Set([
34
+ 'astro.config.mjs',
35
+ generatedImagesManifestLabel,
36
+ 'package-lock.json',
37
+ 'package.json',
38
+ siteConfigLabel,
39
+ siteThemeLabel,
40
+ `${sitePublicLabel}/CNAME`,
41
+ `${sitePublicLabel}/favicon.ico`,
42
+ `${sitePublicLabel}/favicon.svg`,
43
+ `${sitePublicLabel}/robots.txt`,
44
+ `${sitePublicLabel}/sitemap.xml`,
45
+ 'tsconfig.json',
46
+ ]);
47
+ const failedConclusions = new Set(['action_required', 'cancelled', 'failure', 'startup_failure', 'timed_out']);
48
+
49
+ const deployUsage = [
50
+ 'Usage: norna deploy',
51
+ '',
52
+ `Publishes an already committed ${branch} branch: builds, verifies a clean worktree,`,
53
+ `pushes ${branch} when local ${branch} is ahead of origin/${branch}, and checks GitHub Pages.`,
54
+ `If local ${branch} already matches origin/${branch}, it skips push and checks Pages.`,
55
+ '',
56
+ 'For the old build-and-commit convenience flow, use:',
57
+ 'norna deploy:commit "Commit message"',
58
+ ].join('\n');
59
+ const deployCommitUsage = [
60
+ 'Usage: norna deploy:commit "Commit message"',
61
+ '',
62
+ `Builds, stages only allowed site changes, commits, pushes ${branch},`,
63
+ 'and checks GitHub Pages.',
64
+ ].join('\n');
65
+
66
+ const fail = (message) => {
67
+ console.error(message);
68
+ process.exit(1);
69
+ };
70
+
71
+ const runCapture = async (command, commandArgs, options = {}) => {
72
+ const { stdout } = await execFileAsync(command, commandArgs, {
73
+ cwd: root,
74
+ maxBuffer: 1024 * 1024 * 20,
75
+ ...options,
76
+ });
77
+
78
+ return stdout;
79
+ };
80
+
81
+ const runInherit = (command, commandArgs) => new Promise((resolve, reject) => {
82
+ const child = spawn(command, commandArgs, {
83
+ cwd: root,
84
+ stdio: 'inherit',
85
+ });
86
+
87
+ child.once('error', reject);
88
+ child.once('exit', (code, signal) => {
89
+ if (code === 0) {
90
+ resolve();
91
+ return;
92
+ }
93
+
94
+ const commandText = [command, ...commandArgs].join(' ');
95
+ reject(new Error(signal
96
+ ? `${commandText} exited with signal ${signal}.`
97
+ : `${commandText} exited with code ${code}.`));
98
+ });
99
+ });
100
+
101
+ const runBuild = async () => {
102
+ await runInherit(process.execPath, [path.join(engineRoot, 'scripts', 'build-site.mjs')]);
103
+ };
104
+
105
+ const printStatusShort = async () => {
106
+ const status = await runCapture('git', ['status', '--short']);
107
+ console.log('git status --short');
108
+ console.log(status.trim() || '(clean)');
109
+ };
110
+
111
+ const parseStatus = (statusBuffer) => {
112
+ const records = statusBuffer.toString('utf8').split('\0');
113
+ const entries = [];
114
+
115
+ for (let index = 0; index < records.length;) {
116
+ const record = records[index];
117
+ index += 1;
118
+
119
+ if (!record) continue;
120
+
121
+ const status = record.slice(0, 2);
122
+ const filePath = record.slice(3);
123
+ const entry = { status, path: filePath, fromPath: null };
124
+
125
+ if (status.includes('R') || status.includes('C')) {
126
+ entry.fromPath = records[index] || null;
127
+ index += 1;
128
+ }
129
+
130
+ entries.push(entry);
131
+ }
132
+
133
+ return entries;
134
+ };
135
+
136
+ const getStatusEntries = async () => parseStatus(await runCapture(
137
+ 'git',
138
+ ['status', '--porcelain=v1', '-z'],
139
+ { encoding: 'buffer' },
140
+ ));
141
+
142
+ const getExpectedImagePaths = async () => {
143
+ const contentFiles = await getContentFiles();
144
+ const imagePaths = new Set();
145
+
146
+ for (const contentFile of contentFiles) {
147
+ const { frontmatter } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
148
+
149
+ for (const section of getFrontmatterSections(frontmatter)) {
150
+ for (const image of section.images) {
151
+ if (image.includes('/') || image.includes('\\')) continue;
152
+ if (!supportedImageExtensions.has(path.extname(image).toLowerCase())) continue;
153
+
154
+ imagePaths.add(toPosixPath(path.join(contentFile.imagesLabel, section.id, image)));
155
+ }
156
+ }
157
+ }
158
+
159
+ return imagePaths;
160
+ };
161
+
162
+ const isUntracked = (entry) => entry.status === '??';
163
+
164
+ const isExpectedUntracked = (entry, expectedImagePaths) => (
165
+ isUntracked(entry)
166
+ && (
167
+ expectedImagePaths.has(entry.path)
168
+ || entry.path === generatedImagesManifestLabel
169
+ || entry.path.startsWith(`${sitePublicLabel}/`)
170
+ || entry.path.startsWith(`${siteRoutesLabel}/`)
171
+ )
172
+ );
173
+
174
+ const isAllowedPath = (entry, filePath, expectedImagePaths) => (
175
+ filePath === siteContentLabel
176
+ || filePath === siteThemeLabel
177
+ || (!isUntracked(entry) && filePath.startsWith(`${siteImagesLabel}/`))
178
+ || filePath.startsWith(`${siteRoutesLabel}/`)
179
+ || filePath.startsWith(`${sitePublicLabel}/`)
180
+ || expectedImagePaths.has(filePath)
181
+ || filePath.startsWith('src/')
182
+ || allowedExactPaths.has(filePath)
183
+ );
184
+
185
+ const getEntryPaths = (entry) => [entry.path, entry.fromPath].filter(Boolean);
186
+
187
+ const formatEntry = (entry) => `${entry.status} ${getEntryPaths(entry).join(' <- ')}`;
188
+
189
+ const assertMainBranch = async () => {
190
+ const currentBranch = (await runCapture('git', ['branch', '--show-current'])).trim();
191
+
192
+ if (currentBranch !== branch) {
193
+ fail(`Refusing to deploy from branch "${currentBranch || '(detached HEAD)'}". Switch to ${branch} first.`);
194
+ }
195
+ };
196
+
197
+ const assertDeployableStatus = async (entries, expectedImagePaths) => {
198
+ if (entries.length === 0) {
199
+ fail('Refusing to deploy: no changes to commit after the gallery build.');
200
+ }
201
+
202
+ const unexpectedUntracked = entries.filter((entry) => (
203
+ isUntracked(entry) && !isExpectedUntracked(entry, expectedImagePaths)
204
+ ));
205
+
206
+ if (unexpectedUntracked.length > 0) {
207
+ fail([
208
+ 'Refusing to deploy: unexpected untracked files are present.',
209
+ `Only new referenced gallery images under ${siteImagesLabel}/<section-id>/ or ${siteRoutesLabel}/<route-folder>/images/<section-id>/ are staged automatically.`,
210
+ ...unexpectedUntracked.map((entry) => `- ${formatEntry(entry)}`),
211
+ ].join('\n'));
212
+ }
213
+
214
+ const unexpectedEntries = entries.filter((entry) => (
215
+ !getEntryPaths(entry).every((filePath) => isAllowedPath(entry, filePath, expectedImagePaths))
216
+ ));
217
+
218
+ if (unexpectedEntries.length > 0) {
219
+ fail([
220
+ 'Refusing to deploy: changes outside the deploy allowlist are present.',
221
+ 'Commit them separately or update the deploy script deliberately.',
222
+ ...unexpectedEntries.map((entry) => `- ${formatEntry(entry)}`),
223
+ ].join('\n'));
224
+ }
225
+ };
226
+
227
+ const getStagePaths = (entries) => [...new Set(entries.flatMap(getEntryPaths))].sort();
228
+
229
+ const assertCleanWorktree = async (message) => {
230
+ const entries = await getStatusEntries();
231
+
232
+ if (entries.length > 0) {
233
+ await printStatusShort();
234
+ fail(message);
235
+ }
236
+ };
237
+
238
+ const fetchRemoteMain = async () => {
239
+ await runInherit('git', ['fetch', 'origin']);
240
+ };
241
+
242
+ const getRemoteRelation = async () => {
243
+ const output = (await runCapture('git', [
244
+ 'rev-list',
245
+ '--left-right',
246
+ '--count',
247
+ `origin/${branch}...HEAD`,
248
+ ])).trim();
249
+ const [behind, ahead] = output.split(/\s+/).map(Number);
250
+
251
+ if (!Number.isInteger(behind) || !Number.isInteger(ahead)) {
252
+ fail(`Could not compare HEAD with origin/${branch}.`);
253
+ }
254
+
255
+ return { ahead, behind };
256
+ };
257
+
258
+ const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
259
+
260
+ const assertPushableBranch = async () => {
261
+ const relation = await getRemoteRelation();
262
+
263
+ if (relation.behind > 0 && relation.ahead > 0) {
264
+ fail([
265
+ `Refusing to deploy: local ${branch} and origin/${branch} have diverged.`,
266
+ `Local ${branch} is ${plural(relation.ahead, 'commit')} ahead and ${plural(relation.behind, 'commit')} behind origin/${branch}.`,
267
+ 'Reconcile the branches before deploying.',
268
+ ].join('\n'));
269
+ }
270
+
271
+ if (relation.behind > 0) {
272
+ fail([
273
+ `Refusing to deploy: local ${branch} is ${plural(relation.behind, 'commit')} behind origin/${branch}.`,
274
+ 'Pull or rebase before deploying.',
275
+ ].join('\n'));
276
+ }
277
+
278
+ return relation;
279
+ };
280
+
281
+ const getLatestPagesRun = async () => {
282
+ const output = await runCapture('gh', [
283
+ 'run',
284
+ 'list',
285
+ '--repo',
286
+ repo,
287
+ '--workflow',
288
+ pagesWorkflow,
289
+ '--branch',
290
+ branch,
291
+ '--limit',
292
+ '1',
293
+ '--json',
294
+ 'databaseId,conclusion,status,url',
295
+ ]);
296
+ const runs = JSON.parse(output || '[]');
297
+
298
+ return runs[0] ?? null;
299
+ };
300
+
301
+ const checkPagesWorkflow = async () => {
302
+ await runInherit('gh', ['run', 'list', '--repo', repo, '--branch', branch, '--limit', '3']);
303
+
304
+ const latestRun = await getLatestPagesRun();
305
+ if (!latestRun) {
306
+ console.warn(`No ${pagesWorkflow} runs found for ${branch}.`);
307
+ process.exit(0);
308
+ }
309
+
310
+ if (failedConclusions.has(latestRun.conclusion)) {
311
+ console.error(`${pagesWorkflow} run ${latestRun.databaseId} failed. Inspecting failed logs.`);
312
+ await runInherit('gh', ['run', 'view', String(latestRun.databaseId), '--repo', repo, '--log-failed']);
313
+ process.exit(1);
314
+ }
315
+
316
+ console.log(`${pagesWorkflow} latest run: ${latestRun.status}${latestRun.conclusion ? `/${latestRun.conclusion}` : ''}`);
317
+ };
318
+
319
+ const deployCommittedMain = async () => {
320
+ if (modeArgs.length > 0) {
321
+ fail([
322
+ 'The deploy command no longer accepts a commit message.',
323
+ 'Commit your changes first, then run norna deploy.',
324
+ 'Use norna deploy:commit "Commit message" for the old build-and-commit convenience flow.',
325
+ ].join('\n'));
326
+ }
327
+
328
+ await assertMainBranch();
329
+ await assertCleanWorktree('Refusing to deploy: commit or discard local changes before deploying.');
330
+ await fetchRemoteMain();
331
+ await assertPushableBranch();
332
+ await runBuild();
333
+ await printStatusShort();
334
+ await assertCleanWorktree('Refusing to deploy: the gallery build produced uncommitted changes. Commit them before deploying.');
335
+ await fetchRemoteMain();
336
+
337
+ const relation = await assertPushableBranch();
338
+
339
+ if (relation.ahead > 0) {
340
+ await runInherit('git', ['push', 'origin', branch]);
341
+ } else {
342
+ console.log(`Local ${branch} matches origin/${branch}; nothing to push.`);
343
+ }
344
+
345
+ await checkPagesWorkflow();
346
+ };
347
+
348
+ const deployWithCommit = async () => {
349
+ const commitMessage = modeArgs.join(' ').trim();
350
+
351
+ if (!commitMessage) {
352
+ fail(`Commit message is required.\n${deployCommitUsage}`);
353
+ }
354
+
355
+ await assertMainBranch();
356
+ await fetchRemoteMain();
357
+ await assertPushableBranch();
358
+ await runBuild();
359
+ await printStatusShort();
360
+
361
+ const entries = await getStatusEntries();
362
+ const expectedImagePaths = await getExpectedImagePaths();
363
+ await assertDeployableStatus(entries, expectedImagePaths);
364
+
365
+ const stagePaths = getStagePaths(entries);
366
+ await runInherit('git', ['add', '--', ...stagePaths]);
367
+ await runInherit('git', ['commit', '-m', commitMessage]);
368
+ await assertCleanWorktree('Refusing to push: uncommitted changes remain after commit.');
369
+ await fetchRemoteMain();
370
+ await assertPushableBranch();
371
+ await runInherit('git', ['push', 'origin', branch]);
372
+ await checkPagesWorkflow();
373
+ };
374
+
375
+ if (modeArgs.includes('--help') || modeArgs.includes('-h')) {
376
+ console.log(mode === 'commit' ? deployCommitUsage : deployUsage);
377
+ process.exit(0);
378
+ }
379
+
380
+ try {
381
+ if (mode === 'commit') {
382
+ await deployWithCommit();
383
+ } else {
384
+ await deployCommittedMain();
385
+ }
386
+ } catch (error) {
387
+ console.error(error.message);
388
+ process.exit(1);
389
+ }
@@ -0,0 +1,313 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import net from 'node:net';
4
+ import { networkInterfaces } from 'node:os';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { getAstroArgs, runAstroInherit } from './lib/astro-command.mjs';
8
+ import { astroCacheDir, engineRoot, siteProjectRoot } from './lib/site-paths.mjs';
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ const port = 4321;
13
+ const localHost = 'localhost';
14
+ const localUrl = `http://${localHost}:${port}/`;
15
+ const probeUrls = [localUrl, `http://127.0.0.1:${port}/`, `http://[::1]:${port}/`];
16
+ const skipOpen = process.env.WALDE_NO_OPEN === '1';
17
+ const statePath = path.join(astroCacheDir, 'dev-local.json');
18
+ const logPath = path.join(astroCacheDir, 'dev.log');
19
+ const command = process.argv[2] ?? 'start';
20
+
21
+ const runAstro = async (args, options = {}) => execFileAsync(process.execPath, getAstroArgs(args), {
22
+ cwd: siteProjectRoot,
23
+ maxBuffer: 1024 * 1024 * 10,
24
+ ...options,
25
+ });
26
+
27
+ const syncSitePublic = async () => execFileAsync(process.execPath, [path.join(engineRoot, 'scripts', 'sync-site-public.mjs')], {
28
+ cwd: siteProjectRoot,
29
+ maxBuffer: 1024 * 1024 * 10,
30
+ });
31
+
32
+ const getPortPids = async () => {
33
+ try {
34
+ const { stdout } = await execFileAsync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
35
+ return [...new Set(stdout.trim().split(/\s+/).map(Number).filter(Number.isInteger))];
36
+ } catch (error) {
37
+ if (error.code === 1 || error.code === 'ENOENT') {
38
+ return [];
39
+ }
40
+
41
+ throw error;
42
+ }
43
+ };
44
+
45
+ const readState = async () => {
46
+ try {
47
+ return JSON.parse(await readFile(statePath, 'utf8'));
48
+ } catch (error) {
49
+ if (error.code === 'ENOENT') {
50
+ return null;
51
+ }
52
+
53
+ throw error;
54
+ }
55
+ };
56
+
57
+ const getLanUrls = () => Object.values(networkInterfaces())
58
+ .flat()
59
+ .filter((network) => network?.family === 'IPv4' && !network.internal)
60
+ .map((network) => `http://${network.address}:${port}/`);
61
+
62
+ const writeState = async (pid, host) => {
63
+ await mkdir(path.dirname(statePath), { recursive: true });
64
+ await writeFile(statePath, `${JSON.stringify({
65
+ pid,
66
+ port,
67
+ host,
68
+ mode: host === '0.0.0.0' ? 'lan' : 'local',
69
+ url: localUrl,
70
+ startedAt: new Date().toISOString(),
71
+ }, null, 2)}\n`);
72
+ };
73
+
74
+ const removeState = async () => {
75
+ await rm(statePath, { force: true });
76
+ };
77
+
78
+ const isPortFreeOnHost = (loopbackHost) => new Promise((resolve, reject) => {
79
+ const server = net.createServer();
80
+
81
+ server.once('error', (error) => {
82
+ if (error.code === 'EADDRINUSE') {
83
+ resolve(false);
84
+ return;
85
+ }
86
+
87
+ if (error.code === 'EADDRNOTAVAIL') {
88
+ resolve(true);
89
+ return;
90
+ }
91
+
92
+ reject(error);
93
+ });
94
+ server.once('listening', () => {
95
+ server.close(() => resolve(true));
96
+ });
97
+ server.listen({ port, host: loopbackHost, ipv6Only: loopbackHost === '::1' });
98
+ });
99
+
100
+ const isPortFree = async () => {
101
+ for (const loopbackHost of ['127.0.0.1', '::1']) {
102
+ if (!(await isPortFreeOnHost(loopbackHost))) {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ return true;
108
+ };
109
+
110
+ const sleep = (milliseconds) => new Promise((resolve) => {
111
+ setTimeout(resolve, milliseconds);
112
+ });
113
+
114
+ const isServerReachable = async () => {
115
+ for (const probeUrl of probeUrls) {
116
+ try {
117
+ const response = await fetch(probeUrl, { signal: AbortSignal.timeout(1_000) });
118
+ await response.arrayBuffer();
119
+ return true;
120
+ } catch {
121
+ // Try the next loopback address.
122
+ }
123
+ }
124
+
125
+ return false;
126
+ };
127
+
128
+ const waitForServer = async () => {
129
+ const startedAt = Date.now();
130
+ const timeoutMs = 30_000;
131
+
132
+ while (Date.now() - startedAt < timeoutMs) {
133
+ if (await isServerReachable()) {
134
+ return;
135
+ }
136
+
137
+ await sleep(500);
138
+ }
139
+
140
+ throw new Error(`Timed out waiting for ${localUrl}`);
141
+ };
142
+
143
+ const waitForPidToStopListening = async (pid) => {
144
+ const startedAt = Date.now();
145
+ const timeoutMs = 5_000;
146
+
147
+ while (Date.now() - startedAt < timeoutMs) {
148
+ if (!(await getPortPids()).includes(pid)) {
149
+ return true;
150
+ }
151
+
152
+ await sleep(250);
153
+ }
154
+
155
+ return false;
156
+ };
157
+
158
+ const openBrowser = async () => {
159
+ if (skipOpen) {
160
+ console.log(`Browser open skipped. Open ${localUrl}`);
161
+ return;
162
+ }
163
+
164
+ if (process.platform === 'darwin') {
165
+ await execFileAsync('open', [localUrl]);
166
+ return;
167
+ }
168
+
169
+ if (process.platform === 'win32') {
170
+ await execFileAsync('cmd', ['/c', 'start', '', localUrl]);
171
+ return;
172
+ }
173
+
174
+ await execFileAsync('xdg-open', [localUrl]);
175
+ };
176
+
177
+ const stopServer = async ({ quiet = false } = {}) => {
178
+ const state = await readState();
179
+
180
+ await runAstro(['dev', 'stop']).catch(() => {});
181
+
182
+ if (!state?.pid) {
183
+ if (!quiet) {
184
+ console.log('No dev:local server is tracked.');
185
+ }
186
+ return;
187
+ }
188
+
189
+ const listeningPids = await getPortPids();
190
+
191
+ if (!listeningPids.includes(state.pid)) {
192
+ await removeState();
193
+ if (!quiet) {
194
+ console.log('No tracked dev:local server is running.');
195
+ }
196
+ return;
197
+ }
198
+
199
+ process.kill(state.pid, 'SIGTERM');
200
+
201
+ if (!(await waitForPidToStopListening(state.pid))) {
202
+ process.kill(state.pid, 'SIGKILL');
203
+ await waitForPidToStopListening(state.pid);
204
+ }
205
+
206
+ await removeState();
207
+
208
+ if (!quiet) {
209
+ console.log(`Stopped dev server on ${localUrl}.`);
210
+ }
211
+ };
212
+
213
+ const startServer = async ({ host = localHost, open = true } = {}) => {
214
+ await stopServer({ quiet: true });
215
+
216
+ const existingPids = await getPortPids();
217
+ if (existingPids.length > 0 || !(await isPortFree())) {
218
+ throw new Error(`Port ${port} is already in use. Stop the process using it, then rerun the dev command.`);
219
+ }
220
+
221
+ await syncSitePublic();
222
+ await runAstroInherit(['dev', '--background', '--host', host, '--port', String(port)]);
223
+ await waitForServer();
224
+
225
+ const startedPids = await getPortPids();
226
+ await writeState(startedPids[0] ?? null, host);
227
+ if (open) {
228
+ await openBrowser();
229
+ } else {
230
+ console.log(`Browser open skipped. Open ${localUrl}`);
231
+ }
232
+
233
+ console.log(`Astro dev server is running at ${localUrl}`);
234
+ if (host === '0.0.0.0') {
235
+ const lanUrls = getLanUrls();
236
+ console.log(lanUrls.length > 0
237
+ ? `On this local network: ${lanUrls.join(', ')}`
238
+ : 'No local IPv4 address was found for LAN access.');
239
+ }
240
+ console.log('Manage it with norna dev:status, norna dev:logs, norna dev:restart, and norna dev:stop.');
241
+ };
242
+
243
+ const showStatus = async () => {
244
+ const state = await readState();
245
+ const listeningPids = await getPortPids();
246
+ const reachable = await isServerReachable();
247
+
248
+ if (state?.pid && listeningPids.includes(state.pid)) {
249
+ const reachability = reachable ? '' : ' The listener is active, but the URL probe did not respond.';
250
+ console.log(`dev:${state.mode ?? 'local'} is running at ${localUrl} (pid ${state.pid}).${reachability}`);
251
+ if (state.host === '0.0.0.0') {
252
+ const lanUrls = getLanUrls();
253
+ if (lanUrls.length > 0) console.log(`On this local network: ${lanUrls.join(', ')}`);
254
+ }
255
+ return;
256
+ }
257
+
258
+ if (reachable) {
259
+ const detail = state?.pid
260
+ ? `the tracked pid ${state.pid} is not listening`
261
+ : 'no dev:local state file was found';
262
+ console.log(`A server is responding at ${localUrl}, but ${detail}.`);
263
+ return;
264
+ }
265
+
266
+ if (state) {
267
+ await removeState();
268
+ }
269
+
270
+ console.log(`No dev server is running at ${localUrl}.`);
271
+ };
272
+
273
+ const showLogs = async () => {
274
+ const shouldFollow = process.argv.includes('--follow');
275
+
276
+ if (shouldFollow) {
277
+ const tail = spawn('tail', ['-n', '80', '-f', logPath], { stdio: 'inherit' });
278
+ await new Promise((resolve, reject) => {
279
+ tail.once('exit', resolve);
280
+ tail.once('error', reject);
281
+ });
282
+ return;
283
+ }
284
+
285
+ try {
286
+ const lines = (await readFile(logPath, 'utf8')).trimEnd().split('\n');
287
+ console.log(lines.slice(-80).join('\n'));
288
+ } catch (error) {
289
+ if (error.code === 'ENOENT') {
290
+ console.log('No dev log found.');
291
+ return;
292
+ }
293
+
294
+ throw error;
295
+ }
296
+ };
297
+
298
+ if (command === 'start') {
299
+ await startServer({ open: !skipOpen });
300
+ } else if (command === 'lan') {
301
+ await startServer({ host: '0.0.0.0', open: !skipOpen });
302
+ } else if (command === 'status') {
303
+ await showStatus();
304
+ } else if (command === 'logs') {
305
+ await showLogs();
306
+ } else if (command === 'restart') {
307
+ const state = await readState();
308
+ await startServer({ host: state?.host === '0.0.0.0' ? '0.0.0.0' : localHost, open: false });
309
+ } else if (command === 'stop') {
310
+ await stopServer();
311
+ } else {
312
+ throw new Error(`Unknown dev-local command: ${command}`);
313
+ }