@poetora/cli 0.1.8 → 0.1.10
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/cli-builder.js +1 -1
- package/bin/services/version.service.d.ts +1 -1
- package/bin/services/version.service.js +12 -10
- package/package.json +8 -3
- package/.turbo/turbo-build.log +0 -4
- package/src/accessibility.ts +0 -180
- package/src/cli-builder.ts +0 -274
- package/src/cli.ts +0 -22
- package/src/commands/__tests__/base.command.test.ts +0 -139
- package/src/commands/__tests__/dev.command.test.ts +0 -241
- package/src/commands/__tests__/init.command.test.ts +0 -281
- package/src/commands/__tests__/utils.ts +0 -20
- package/src/commands/base.command.ts +0 -97
- package/src/commands/check.command.ts +0 -40
- package/src/commands/dev.command.ts +0 -63
- package/src/commands/index.ts +0 -6
- package/src/commands/init.command.ts +0 -125
- package/src/commands/link.command.ts +0 -39
- package/src/commands/update.command.ts +0 -23
- package/src/constants.ts +0 -4
- package/src/errors/cli-error.ts +0 -83
- package/src/errors/index.ts +0 -1
- package/src/index.ts +0 -110
- package/src/mdxAccessibility.ts +0 -132
- package/src/middlewares.ts +0 -73
- package/src/services/__tests__/port.service.test.ts +0 -83
- package/src/services/__tests__/template.service.test.ts +0 -234
- package/src/services/__tests__/version.service.test.ts +0 -165
- package/src/services/accessibility-check.service.ts +0 -226
- package/src/services/index.ts +0 -7
- package/src/services/link.service.ts +0 -65
- package/src/services/openapi-check.service.ts +0 -68
- package/src/services/port.service.ts +0 -47
- package/src/services/template.service.ts +0 -203
- package/src/services/update.service.ts +0 -76
- package/src/services/version.service.ts +0 -161
- package/src/start.ts +0 -6
- package/src/types/common.ts +0 -53
- package/src/types/index.ts +0 -2
- package/src/types/options.ts +0 -42
- package/src/utils/console-logger.ts +0 -123
- package/src/utils/index.ts +0 -2
- package/src/utils/logger.interface.ts +0 -70
- package/tsconfig.build.json +0 -17
- package/tsconfig.json +0 -21
- package/vitest.config.ts +0 -8
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
import { getConfigObj, getConfigPath } from '@poetora/prebuild';
|
|
2
|
-
import { getBackgroundColors } from '@poetora/shared';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import {
|
|
5
|
-
type AccessibilityCheckResult,
|
|
6
|
-
type ContrastResult,
|
|
7
|
-
checkDocsColors,
|
|
8
|
-
} from '../accessibility.js';
|
|
9
|
-
import { CMD_EXEC_PATH } from '../constants.js';
|
|
10
|
-
import { type AccessibilityFixAttribute, checkMdxAccessibility } from '../mdxAccessibility.js';
|
|
11
|
-
import { ConsoleLogger } from '../utils/console-logger.js';
|
|
12
|
-
import type { ILogger } from '../utils/logger.interface.js';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Service for accessibility checking
|
|
16
|
-
*/
|
|
17
|
-
export class AccessibilityCheckService {
|
|
18
|
-
constructor(private readonly logger: ILogger = new ConsoleLogger()) {}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Display contrast result with color-coded output
|
|
22
|
-
*/
|
|
23
|
-
private displayContrastResult(
|
|
24
|
-
result: ContrastResult | null,
|
|
25
|
-
label: string,
|
|
26
|
-
prefix: string = ''
|
|
27
|
-
): void {
|
|
28
|
-
if (!result) return;
|
|
29
|
-
|
|
30
|
-
const { recommendation } = result;
|
|
31
|
-
|
|
32
|
-
// Custom messages with our own tone
|
|
33
|
-
let statusText: string;
|
|
34
|
-
let detailMessage: string;
|
|
35
|
-
|
|
36
|
-
if (recommendation === 'pass') {
|
|
37
|
-
if (result.meetsAAA) {
|
|
38
|
-
statusText = '✓ Excellent';
|
|
39
|
-
detailMessage = `ratio ${result.ratio.toFixed(2)}:1 (AAA standard)`;
|
|
40
|
-
} else {
|
|
41
|
-
statusText = '✓ Good';
|
|
42
|
-
detailMessage = `ratio ${result.ratio.toFixed(2)}:1 (AA standard)`;
|
|
43
|
-
}
|
|
44
|
-
} else if (recommendation === 'warning') {
|
|
45
|
-
statusText = '⚠ Acceptable';
|
|
46
|
-
detailMessage = `ratio ${result.ratio.toFixed(2)}:1 (consider improving)`;
|
|
47
|
-
} else {
|
|
48
|
-
statusText = '✗ Poor';
|
|
49
|
-
detailMessage = `ratio ${result.ratio.toFixed(2)}:1 (below AA standard)`;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Display with label in cyan and status+message in appropriate color
|
|
53
|
-
const labelText = `${prefix}${label}:`;
|
|
54
|
-
const statusColor =
|
|
55
|
-
recommendation === 'pass' ? 'green' : recommendation === 'warning' ? 'yellow' : 'red';
|
|
56
|
-
|
|
57
|
-
this.logger.log(`${labelText} ${this.colorize(statusText, statusColor)} ${detailMessage}`);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
private colorize(text: string, color: 'green' | 'yellow' | 'red'): string {
|
|
61
|
-
// Use chalk directly for inline coloring
|
|
62
|
-
const chalk = (text: string, color: 'green' | 'yellow' | 'red') => {
|
|
63
|
-
const colors: Record<string, string> = {
|
|
64
|
-
green: '\x1b[32m',
|
|
65
|
-
yellow: '\x1b[33m',
|
|
66
|
-
red: '\x1b[31m',
|
|
67
|
-
reset: '\x1b[0m',
|
|
68
|
-
};
|
|
69
|
-
return `${colors[color]}${text}${colors.reset}`;
|
|
70
|
-
};
|
|
71
|
-
return chalk(text, color);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Check color contrast accessibility
|
|
76
|
-
*/
|
|
77
|
-
private async checkColorAccessibility(): /* prettier-ignore */ Promise<number> {
|
|
78
|
-
try {
|
|
79
|
-
const docsConfigPath = await getConfigPath(CMD_EXEC_PATH);
|
|
80
|
-
|
|
81
|
-
if (!docsConfigPath) {
|
|
82
|
-
this.logger.error(
|
|
83
|
-
'No configuration file found. Please run this command from a directory with a docs.json file.'
|
|
84
|
-
);
|
|
85
|
-
return 1;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const config = await getConfigObj(CMD_EXEC_PATH);
|
|
89
|
-
|
|
90
|
-
if (!config?.colors) {
|
|
91
|
-
this.logger.warn('No colors section found in configuration file');
|
|
92
|
-
return 0;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const { colors, navigation } = config;
|
|
96
|
-
const { lightHex, darkHex } = getBackgroundColors(config);
|
|
97
|
-
|
|
98
|
-
const results: AccessibilityCheckResult = checkDocsColors(
|
|
99
|
-
colors,
|
|
100
|
-
{ lightHex, darkHex },
|
|
101
|
-
navigation
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
this.logger.log('Checking color accessibility...');
|
|
105
|
-
|
|
106
|
-
this.displayContrastResult(
|
|
107
|
-
results.primaryContrast,
|
|
108
|
-
`Primary Color (${colors.primary}) vs Light Background`
|
|
109
|
-
);
|
|
110
|
-
this.displayContrastResult(
|
|
111
|
-
results.lightContrast,
|
|
112
|
-
`Light Color (${colors.light}) vs Dark Background`
|
|
113
|
-
);
|
|
114
|
-
this.displayContrastResult(
|
|
115
|
-
results.darkContrast,
|
|
116
|
-
`Dark Color (${colors.dark}) vs Dark Background`
|
|
117
|
-
);
|
|
118
|
-
this.displayContrastResult(
|
|
119
|
-
results.darkOnLightContrast,
|
|
120
|
-
`Dark Color (${colors.dark}) vs Light Background`
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
const anchorsWithResults = results.anchorResults.filter(
|
|
124
|
-
(anchor) => anchor.lightContrast || anchor.darkContrast
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
if (anchorsWithResults.length > 0) {
|
|
128
|
-
for (const anchor of anchorsWithResults) {
|
|
129
|
-
this.displayContrastResult(anchor.lightContrast, `${anchor.name} vs Light Background`);
|
|
130
|
-
this.displayContrastResult(anchor.darkContrast, `${anchor.name} vs Dark Background`);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Overall assessment with custom messages
|
|
135
|
-
let overallDisplay: string;
|
|
136
|
-
if (results.overallScore === 'fail') {
|
|
137
|
-
overallDisplay =
|
|
138
|
-
this.colorize('✗ Action needed:', 'red') +
|
|
139
|
-
' Update colors to meet accessibility standards';
|
|
140
|
-
} else if (results.overallScore === 'warning') {
|
|
141
|
-
overallDisplay = `${this.colorize('⚠ Room for improvement:', 'yellow')} Consider enhancing color contrast`;
|
|
142
|
-
} else {
|
|
143
|
-
overallDisplay = `${this.colorize('✓ All good:', 'green')} Colors meet accessibility guidelines`;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
this.logger.logNewLine();
|
|
147
|
-
this.logger.log(overallDisplay);
|
|
148
|
-
|
|
149
|
-
return results.overallScore === 'fail' ? 1 : 0;
|
|
150
|
-
} catch (error) {
|
|
151
|
-
this.logger.error(`Failed to check color accessibility: ${error}`);
|
|
152
|
-
return 1;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Check MDX files for accessibility issues
|
|
158
|
-
*/
|
|
159
|
-
private async checkMdxAccessibility(): /* prettier-ignore */ Promise<number> {
|
|
160
|
-
try {
|
|
161
|
-
this.logger.log('Checking mdx files for accessibility issues...');
|
|
162
|
-
|
|
163
|
-
const results = await checkMdxAccessibility();
|
|
164
|
-
|
|
165
|
-
if (results.missingAltAttributes.length === 0) {
|
|
166
|
-
this.logger.success('no accessibility issues found');
|
|
167
|
-
this.logger.info(
|
|
168
|
-
`Checked ${results.totalFiles} MDX files - all images and videos have alt attributes.`
|
|
169
|
-
);
|
|
170
|
-
return 0;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const issuesByFile: Record<string, AccessibilityFixAttribute[]> = {};
|
|
174
|
-
results.missingAltAttributes.forEach((issue) => {
|
|
175
|
-
if (!issuesByFile[issue.filePath]) {
|
|
176
|
-
issuesByFile[issue.filePath] = [];
|
|
177
|
-
}
|
|
178
|
-
issuesByFile[issue.filePath]?.push(issue);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
this.logger.log(
|
|
182
|
-
`Found ${this.logger.highlight?.(results.missingAltAttributes.length.toString()) ?? results.missingAltAttributes.length} accessibility issue${results.missingAltAttributes.length === 1 ? '' : 's'} in ${this.logger.highlight?.(results.filesWithIssues.toString()) ?? results.filesWithIssues} file${results.filesWithIssues === 1 ? '' : 's'}:`
|
|
183
|
-
);
|
|
184
|
-
|
|
185
|
-
for (const [filePath, issues] of Object.entries(issuesByFile)) {
|
|
186
|
-
const relativePath = path.relative(process.cwd(), filePath);
|
|
187
|
-
this.logger.log(`${relativePath}:`);
|
|
188
|
-
|
|
189
|
-
for (const issue of issues) {
|
|
190
|
-
const location =
|
|
191
|
-
issue.line && issue.column ? ` (line ${issue.line}, col ${issue.column})` : '';
|
|
192
|
-
if (issue.element === 'a') {
|
|
193
|
-
this.logger.logColor(
|
|
194
|
-
` ✗ Missing text attribute ${issue.tagName} element${location}`,
|
|
195
|
-
'red'
|
|
196
|
-
);
|
|
197
|
-
} else {
|
|
198
|
-
this.logger.logColor(
|
|
199
|
-
` ✗ Missing alt attribute on ${issue.tagName} element${location}`,
|
|
200
|
-
'red'
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
this.logger.warn(
|
|
207
|
-
'Recommendation: Add alt attributes to all images and videos for better accessibility.'
|
|
208
|
-
);
|
|
209
|
-
|
|
210
|
-
return 1;
|
|
211
|
-
} catch (error) {
|
|
212
|
-
this.logger.error(`Failed to check MDX accessibility: ${error}`);
|
|
213
|
-
return 1;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Run accessibility checks on documentation
|
|
219
|
-
* @returns Exit code (0 for success, 1 for failure)
|
|
220
|
-
*/
|
|
221
|
-
async checkAccessibility(): /* prettier-ignore */ Promise<number> {
|
|
222
|
-
const colorCheckCode = await this.checkColorAccessibility();
|
|
223
|
-
const mdxCheckCode = await this.checkMdxAccessibility();
|
|
224
|
-
return colorCheckCode || mdxCheckCode;
|
|
225
|
-
}
|
|
226
|
-
}
|
package/src/services/index.ts
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
export * from './accessibility-check.service.js';
|
|
2
|
-
export * from './link.service.js';
|
|
3
|
-
export * from './openapi-check.service.js';
|
|
4
|
-
export * from './port.service.js';
|
|
5
|
-
export * from './template.service.js';
|
|
6
|
-
export * from './update.service.js';
|
|
7
|
-
export * from './version.service.js';
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
getBrokenInternalLinks,
|
|
3
|
-
type MdxPath,
|
|
4
|
-
renameFilesAndUpdateLinksInContent,
|
|
5
|
-
} from '@poetora/link-rot';
|
|
6
|
-
import * as path from 'path';
|
|
7
|
-
|
|
8
|
-
import type { ILogger } from '../utils/index.js';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Service for link management
|
|
12
|
-
*/
|
|
13
|
-
export class LinkService {
|
|
14
|
-
constructor(private readonly logger: ILogger) {}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Check for broken internal links
|
|
18
|
-
* @returns Broken links grouped by file
|
|
19
|
-
*/
|
|
20
|
-
async checkBrokenLinks(): Promise<Record<string, string[]>> {
|
|
21
|
-
const brokenLinks = await getBrokenInternalLinks();
|
|
22
|
-
|
|
23
|
-
if (brokenLinks.length === 0) {
|
|
24
|
-
this.logger.success('no broken links found');
|
|
25
|
-
return {};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Group broken links by file
|
|
29
|
-
const brokenLinksByFile: Record<string, string[]> = {};
|
|
30
|
-
brokenLinks.forEach((mdxPath: MdxPath) => {
|
|
31
|
-
const filename = path.join(mdxPath.relativeDir, mdxPath.filename);
|
|
32
|
-
const brokenLinksForFile = brokenLinksByFile[filename];
|
|
33
|
-
if (brokenLinksForFile) {
|
|
34
|
-
brokenLinksForFile.push(mdxPath.originalPath);
|
|
35
|
-
} else {
|
|
36
|
-
brokenLinksByFile[filename] = [mdxPath.originalPath];
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
// Display broken links with highlighted numbers
|
|
41
|
-
const fileCount = Object.keys(brokenLinksByFile).length;
|
|
42
|
-
this.logger.log(
|
|
43
|
-
`found ${this.logger.highlight?.(brokenLinks.length.toString()) ?? brokenLinks.length} broken link${brokenLinks.length === 1 ? '' : 's'} in ${this.logger.highlight?.(fileCount.toString()) ?? fileCount} file${fileCount === 1 ? '' : 's'}`
|
|
44
|
-
);
|
|
45
|
-
this.logger.logNewLine();
|
|
46
|
-
|
|
47
|
-
for (const [filename, links] of Object.entries(brokenLinksByFile)) {
|
|
48
|
-
// Underline filename for better visibility
|
|
49
|
-
this.logger.logColor(filename, 'cyan');
|
|
50
|
-
links.forEach((link) => {
|
|
51
|
-
this.logger.logColor(` ⎿ ${link}`, 'gray');
|
|
52
|
-
});
|
|
53
|
-
this.logger.logNewLine();
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return brokenLinksByFile;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Rename a file and update all internal link references
|
|
61
|
-
*/
|
|
62
|
-
async renameFile(from: string, to: string, force: boolean = false): Promise<void> {
|
|
63
|
-
await renameFilesAndUpdateLinksInContent(from, to, force);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { getOpenApiDocumentFromUrl, isAllowedLocalSchemaUrl, validate } from '@poetora/shared';
|
|
2
|
-
import * as fs from 'fs';
|
|
3
|
-
import * as yaml from 'js-yaml';
|
|
4
|
-
|
|
5
|
-
import { FileSystemError, ValidationError } from '../errors/index.js';
|
|
6
|
-
import type { ILogger } from '../utils/index.js';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Service for OpenAPI validation
|
|
10
|
-
*/
|
|
11
|
-
export class OpenApiCheckService {
|
|
12
|
-
constructor(private readonly logger: ILogger) {}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Validate OpenAPI spec from URL or local file
|
|
16
|
-
*/
|
|
17
|
-
async validateSpec(filename: string, localSchema: boolean = false): Promise<void> {
|
|
18
|
-
// Check if it's a URL
|
|
19
|
-
if (isAllowedLocalSchemaUrl(filename, localSchema)) {
|
|
20
|
-
await getOpenApiDocumentFromUrl(filename);
|
|
21
|
-
this.logger.success('OpenAPI definition is valid.');
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Warn for http:// URLs without --local-schema
|
|
26
|
-
if (filename.startsWith('http://') && !localSchema) {
|
|
27
|
-
this.logger.warn('include the --local-schema flag to check locally hosted OpenAPI files');
|
|
28
|
-
this.logger.warn('only https protocol is supported in production');
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Read and validate local file
|
|
33
|
-
const document = await this.readLocalOpenApiFile(filename);
|
|
34
|
-
|
|
35
|
-
if (!document) {
|
|
36
|
-
throw new ValidationError(
|
|
37
|
-
'failed to parse OpenAPI spec: could not parse file correctly, please check for any syntax errors.'
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
await validate(document);
|
|
42
|
-
this.logger.success('OpenAPI definition is valid.');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Read OpenAPI file from local filesystem
|
|
47
|
-
*/
|
|
48
|
-
private async readLocalOpenApiFile(filename: string): Promise<unknown> {
|
|
49
|
-
try {
|
|
50
|
-
const fileContents = await fs.promises.readFile(filename, 'utf-8');
|
|
51
|
-
|
|
52
|
-
// Try to parse as JSON first
|
|
53
|
-
if (filename.endsWith('.json')) {
|
|
54
|
-
return JSON.parse(fileContents);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Try YAML parsing
|
|
58
|
-
return yaml.load(fileContents);
|
|
59
|
-
} catch (error) {
|
|
60
|
-
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
61
|
-
throw new FileSystemError(`file not found, please check the path provided: ${filename}`);
|
|
62
|
-
}
|
|
63
|
-
throw new ValidationError(
|
|
64
|
-
`Failed to parse OpenAPI spec: ${error instanceof Error ? error.message : 'unknown error'}`
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import detect from 'detect-port';
|
|
2
|
-
|
|
3
|
-
import { NoAvailablePortError } from '../errors/index.js';
|
|
4
|
-
import { CLI_CONSTANTS } from '../types/index.js';
|
|
5
|
-
import type { ILogger } from '../utils/index.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Service for handling port detection and availability checks
|
|
9
|
-
*/
|
|
10
|
-
export class PortService {
|
|
11
|
-
constructor(private readonly logger?: ILogger) {}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Find an available port starting from the preferred port
|
|
15
|
-
* @param preferredPort - The preferred port to start from (defaults to 3000)
|
|
16
|
-
* @returns The first available port found
|
|
17
|
-
* @throws {NoAvailablePortError} if no port is available after MAX_ATTEMPTS tries
|
|
18
|
-
*/
|
|
19
|
-
async findAvailablePort(preferredPort?: number): Promise<number> {
|
|
20
|
-
const startPort = preferredPort ?? CLI_CONSTANTS.PORT.DEFAULT;
|
|
21
|
-
|
|
22
|
-
for (let attempt = 0; attempt < CLI_CONSTANTS.PORT.MAX_ATTEMPTS; attempt++) {
|
|
23
|
-
const port = startPort + attempt;
|
|
24
|
-
|
|
25
|
-
if (await this.isPortAvailable(port)) {
|
|
26
|
-
if (attempt > 0 && this.logger) {
|
|
27
|
-
this.logger.info(`port ${startPort} is already in use. using ${port} instead`);
|
|
28
|
-
}
|
|
29
|
-
return port;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
throw new NoAvailablePortError(
|
|
34
|
-
`No available port found after ${CLI_CONSTANTS.PORT.MAX_ATTEMPTS} attempts starting from ${startPort}`
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Check if a specific port is available
|
|
40
|
-
* @param port - The port number to check
|
|
41
|
-
* @returns true if the port is available, false otherwise
|
|
42
|
-
*/
|
|
43
|
-
private async isPortAvailable(port: number): Promise<boolean> {
|
|
44
|
-
const detectedPort = await detect(port);
|
|
45
|
-
return detectedPort === port;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
import { docsConfigSchema } from '@poetora/validation';
|
|
2
|
-
import AdmZip from 'adm-zip';
|
|
3
|
-
import * as fs from 'fs';
|
|
4
|
-
import * as fse from 'fs-extra';
|
|
5
|
-
import * as path from 'path';
|
|
6
|
-
|
|
7
|
-
import { ExternalServiceError, FileSystemError } from '../errors/index.js';
|
|
8
|
-
|
|
9
|
-
export interface DirectoryStatus {
|
|
10
|
-
exists: boolean;
|
|
11
|
-
hasContents: boolean;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface InstallTemplateParams {
|
|
15
|
-
directory: string;
|
|
16
|
-
projectName: string;
|
|
17
|
-
theme: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface DocsConfig {
|
|
21
|
-
name: string;
|
|
22
|
-
theme: string;
|
|
23
|
-
[key: string]: unknown;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Service for managing documentation templates
|
|
28
|
-
*/
|
|
29
|
-
export class TemplateService {
|
|
30
|
-
private readonly TEMPLATE_URL = 'https://github.com/poetora/starter/archive/refs/heads/main.zip';
|
|
31
|
-
private readonly TEMP_ZIP_PATH = 'poetora-starter.zip';
|
|
32
|
-
private readonly TEMP_EXTRACT_DIR = 'poetora-starter-temp';
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Check if directory exists and has contents
|
|
36
|
-
*/
|
|
37
|
-
async checkDirectory(directory: string): Promise<DirectoryStatus> {
|
|
38
|
-
try {
|
|
39
|
-
await fse.ensureDir(directory);
|
|
40
|
-
|
|
41
|
-
const files = await fs.promises.readdir(directory);
|
|
42
|
-
const hasContents = files.length > 0;
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
exists: true,
|
|
46
|
-
hasContents,
|
|
47
|
-
};
|
|
48
|
-
} catch (error) {
|
|
49
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
50
|
-
return {
|
|
51
|
-
exists: false,
|
|
52
|
-
hasContents: false,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
throw new FileSystemError(`Failed to check directory: ${(error as Error).message}`);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Get available themes from validation package
|
|
61
|
-
*/
|
|
62
|
-
getAvailableThemes(): string[] {
|
|
63
|
-
// docsConfigSchema is a discriminated union with 'theme' as the discriminator
|
|
64
|
-
// Extract theme values from each option's shape.theme field
|
|
65
|
-
const themes = docsConfigSchema.options
|
|
66
|
-
.map((option) => {
|
|
67
|
-
// Each option has a shape with theme field (ZodLiteral)
|
|
68
|
-
const themeField = (
|
|
69
|
-
option as unknown as { shape?: { theme?: { _def?: { value?: string } } } }
|
|
70
|
-
).shape?.theme;
|
|
71
|
-
if (themeField?._def && 'value' in themeField._def) {
|
|
72
|
-
return themeField._def.value as string;
|
|
73
|
-
}
|
|
74
|
-
return null;
|
|
75
|
-
})
|
|
76
|
-
.filter((theme): theme is string => theme !== null);
|
|
77
|
-
|
|
78
|
-
return themes.length > 0 ? themes : ['ora'];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Download and install template to target directory
|
|
83
|
-
*/
|
|
84
|
-
async installTemplate(params: InstallTemplateParams): Promise<void> {
|
|
85
|
-
const { directory, projectName, theme } = params;
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
// Download template
|
|
89
|
-
await this.downloadTemplate();
|
|
90
|
-
|
|
91
|
-
// Extract template
|
|
92
|
-
await this.extractTemplate();
|
|
93
|
-
|
|
94
|
-
// Copy files to target directory
|
|
95
|
-
await this.copyTemplateFiles(directory);
|
|
96
|
-
|
|
97
|
-
// Update configuration
|
|
98
|
-
await this.updateDocsConfig(directory, projectName, theme);
|
|
99
|
-
|
|
100
|
-
// Cleanup temporary files
|
|
101
|
-
await this.cleanup();
|
|
102
|
-
} catch (error) {
|
|
103
|
-
// Ensure cleanup on error
|
|
104
|
-
await this.cleanup();
|
|
105
|
-
throw error;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
private async downloadTemplate(): Promise<void> {
|
|
110
|
-
try {
|
|
111
|
-
const response = await fetch(this.TEMPLATE_URL);
|
|
112
|
-
|
|
113
|
-
if (!response.ok) {
|
|
114
|
-
throw new ExternalServiceError(
|
|
115
|
-
`Failed to download template: HTTP ${response.status} ${response.statusText}`
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
120
|
-
const buffer = Buffer.from(arrayBuffer);
|
|
121
|
-
|
|
122
|
-
await fs.promises.writeFile(this.TEMP_ZIP_PATH, buffer);
|
|
123
|
-
} catch (error) {
|
|
124
|
-
if (error instanceof ExternalServiceError) {
|
|
125
|
-
throw error;
|
|
126
|
-
}
|
|
127
|
-
throw new ExternalServiceError(`Failed to download template: ${(error as Error).message}`);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
private async extractTemplate(): Promise<void> {
|
|
132
|
-
try {
|
|
133
|
-
const zip = new AdmZip(this.TEMP_ZIP_PATH);
|
|
134
|
-
zip.extractAllTo(this.TEMP_EXTRACT_DIR, true);
|
|
135
|
-
} catch (error) {
|
|
136
|
-
throw new FileSystemError(`Failed to extract template: ${(error as Error).message}`);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
private async copyTemplateFiles(targetDir: string): Promise<void> {
|
|
141
|
-
try {
|
|
142
|
-
// The extracted folder is typically named "starter-main"
|
|
143
|
-
const extractedFolder = path.join(this.TEMP_EXTRACT_DIR, 'starter-main');
|
|
144
|
-
|
|
145
|
-
// Ensure target directory exists
|
|
146
|
-
await fse.ensureDir(targetDir);
|
|
147
|
-
|
|
148
|
-
// Copy all files from extracted folder to target directory
|
|
149
|
-
await fse.copy(extractedFolder, targetDir, {
|
|
150
|
-
overwrite: true,
|
|
151
|
-
errorOnExist: false,
|
|
152
|
-
});
|
|
153
|
-
} catch (error) {
|
|
154
|
-
throw new FileSystemError(`Failed to copy template files: ${(error as Error).message}`);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
private async updateDocsConfig(
|
|
159
|
-
directory: string,
|
|
160
|
-
projectName: string,
|
|
161
|
-
theme: string
|
|
162
|
-
): Promise<void> {
|
|
163
|
-
try {
|
|
164
|
-
const configPath = path.join(directory, 'docs.json');
|
|
165
|
-
|
|
166
|
-
let config: DocsConfig;
|
|
167
|
-
|
|
168
|
-
// Try to read existing config
|
|
169
|
-
try {
|
|
170
|
-
const configContent = await fs.promises.readFile(configPath, 'utf-8');
|
|
171
|
-
config = JSON.parse(configContent) as DocsConfig;
|
|
172
|
-
} catch {
|
|
173
|
-
// If file doesn't exist or is invalid, create new config
|
|
174
|
-
config = {} as DocsConfig;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Update config with user choices
|
|
178
|
-
config.name = projectName;
|
|
179
|
-
config.theme = theme;
|
|
180
|
-
|
|
181
|
-
// Write updated config
|
|
182
|
-
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
183
|
-
} catch (error) {
|
|
184
|
-
throw new FileSystemError(`Failed to update docs.json: ${(error as Error).message}`);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
private async cleanup(): Promise<void> {
|
|
189
|
-
try {
|
|
190
|
-
// Remove temporary files
|
|
191
|
-
await Promise.all([
|
|
192
|
-
fse.remove(this.TEMP_ZIP_PATH).catch(() => {
|
|
193
|
-
/* ignore */
|
|
194
|
-
}),
|
|
195
|
-
fse.remove(this.TEMP_EXTRACT_DIR).catch(() => {
|
|
196
|
-
/* ignore */
|
|
197
|
-
}),
|
|
198
|
-
]);
|
|
199
|
-
} catch {
|
|
200
|
-
// Ignore cleanup errors
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { exec } from 'child_process';
|
|
2
|
-
import { promisify } from 'util';
|
|
3
|
-
import { ExternalServiceError } from '../errors/index.js';
|
|
4
|
-
import type { ILogger } from '../utils/index.js';
|
|
5
|
-
import type { VersionService } from './version.service.js';
|
|
6
|
-
|
|
7
|
-
const execAsync = promisify(exec);
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Service for CLI updates
|
|
11
|
-
*/
|
|
12
|
-
export class UpdateService {
|
|
13
|
-
constructor(
|
|
14
|
-
private readonly logger: ILogger,
|
|
15
|
-
private readonly versionService: VersionService,
|
|
16
|
-
private readonly packageName: string
|
|
17
|
-
) {}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Update CLI to latest version
|
|
21
|
-
*/
|
|
22
|
-
async update(): Promise<void> {
|
|
23
|
-
const spinner = this.logger.spinner('checking for updates...');
|
|
24
|
-
spinner.start();
|
|
25
|
-
|
|
26
|
-
const existingCliVersion = this.versionService.getCliVersion();
|
|
27
|
-
const latestCliVersion = this.versionService.getLatestCliVersion(this.packageName);
|
|
28
|
-
|
|
29
|
-
const isUpToDate =
|
|
30
|
-
existingCliVersion &&
|
|
31
|
-
latestCliVersion &&
|
|
32
|
-
latestCliVersion.trim() === existingCliVersion.trim();
|
|
33
|
-
|
|
34
|
-
if (isUpToDate) {
|
|
35
|
-
spinner.succeed('already up to date');
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (existingCliVersion && latestCliVersion.trim() !== existingCliVersion.trim()) {
|
|
40
|
-
try {
|
|
41
|
-
spinner.stop();
|
|
42
|
-
const updateSpinner = this.logger.spinner(`updating ${this.packageName} package...`);
|
|
43
|
-
updateSpinner.start();
|
|
44
|
-
|
|
45
|
-
const packageManager = await this.detectPackageManager();
|
|
46
|
-
|
|
47
|
-
if (packageManager === 'pnpm') {
|
|
48
|
-
await execAsync(`pnpm install -g ${this.packageName}@latest --silent`);
|
|
49
|
-
} else {
|
|
50
|
-
await execAsync(`npm install -g ${this.packageName}@latest --silent`);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
updateSpinner.succeed(
|
|
54
|
-
`updated ${this.packageName} to version ${this.logger.highlight?.(latestCliVersion) ?? latestCliVersion}`
|
|
55
|
-
);
|
|
56
|
-
} catch (_err) {
|
|
57
|
-
throw new ExternalServiceError(`failed to update ${this.packageName}@latest`);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Detect which package manager was used to install the CLI
|
|
64
|
-
*/
|
|
65
|
-
private async detectPackageManager(): Promise<'npm' | 'pnpm'> {
|
|
66
|
-
try {
|
|
67
|
-
const { stdout: packagePath } = await execAsync(`which ${this.packageName}`);
|
|
68
|
-
if (packagePath.includes('pnpm')) {
|
|
69
|
-
return 'pnpm';
|
|
70
|
-
}
|
|
71
|
-
return 'npm';
|
|
72
|
-
} catch (_error) {
|
|
73
|
-
return 'npm';
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|