@kntic/links 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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * links list — List all configured links.
3
+ *
4
+ * Reads the nearest links.yaml and prints a formatted table of links.
5
+ * Supports --json for machine-readable output.
6
+ */
7
+
8
+ import chalk from 'chalk';
9
+ import { findConfig, readConfig } from '../config.js';
10
+
11
+ /**
12
+ * Build a schedule status string for a link.
13
+ *
14
+ * Format:
15
+ * ⏰ from 2026-04-01 (only scheduled_from)
16
+ * ⏰ until 2026-12-31 (only scheduled_until)
17
+ * ⏰ 2026-04-01 → 2026-12-31 (both)
18
+ * '' (always active)
19
+ */
20
+ function scheduleStatus(link) {
21
+ const hasFrom = !!link.scheduled_from;
22
+ const hasUntil = !!link.scheduled_until;
23
+
24
+ if (hasFrom && hasUntil) {
25
+ return `⏰ ${link.scheduled_from} → ${link.scheduled_until}`;
26
+ }
27
+ if (hasFrom) {
28
+ return `⏰ from ${link.scheduled_from}`;
29
+ }
30
+ if (hasUntil) {
31
+ return `⏰ until ${link.scheduled_until}`;
32
+ }
33
+ return '';
34
+ }
35
+
36
+ /**
37
+ * Pad a string to a given width.
38
+ */
39
+ function pad(str, width) {
40
+ const len = str.length;
41
+ return len >= width ? str : str + ' '.repeat(width - len);
42
+ }
43
+
44
+ export function registerList(program) {
45
+ program
46
+ .command('list')
47
+ .description('List all links')
48
+ .option('--json', 'Output links as a JSON array')
49
+ .action(async (opts) => {
50
+ // Find and read config
51
+ let config;
52
+ try {
53
+ const configPath = findConfig();
54
+ config = readConfig(configPath);
55
+ } catch (err) {
56
+ console.error(`Error: ${err.message}`);
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+
61
+ const links = config.links || [];
62
+
63
+ if (links.length === 0) {
64
+ console.log('No links yet. Use: links add <label> <url>');
65
+ return;
66
+ }
67
+
68
+ // JSON output
69
+ if (opts.json) {
70
+ console.log(JSON.stringify(links, null, 2));
71
+ return;
72
+ }
73
+
74
+ // Calculate column widths
75
+ const idxWidth = String(links.length).length;
76
+ let labelWidth = 5; // minimum "LABEL"
77
+ let urlWidth = 3; // minimum "URL"
78
+ let iconWidth = 0;
79
+
80
+ const hasIcons = links.some((l) => l.icon);
81
+
82
+ for (const link of links) {
83
+ if (link.label.length > labelWidth) labelWidth = link.label.length;
84
+ if (link.url.length > urlWidth) urlWidth = link.url.length;
85
+ if (link.icon && link.icon.length > iconWidth) iconWidth = link.icon.length;
86
+ }
87
+
88
+ // Print header
89
+ let header = `${pad('#', idxWidth)} ${pad('LABEL', labelWidth)} ${pad('URL', urlWidth)}`;
90
+ if (hasIcons) header += ` ${pad('ICON', iconWidth)}`;
91
+ header += ' SCHEDULE';
92
+ console.log(chalk.bold(header));
93
+ console.log(chalk.gray('─'.repeat(header.length)));
94
+
95
+ // Print rows
96
+ links.forEach((link, i) => {
97
+ const idx = pad(String(i + 1), idxWidth);
98
+ const label = pad(link.label, labelWidth);
99
+ const url = pad(link.url, urlWidth);
100
+ const icon = hasIcons ? ` ${pad(link.icon || '', iconWidth)}` : '';
101
+ const sched = scheduleStatus(link);
102
+
103
+ let line = `${chalk.gray(idx)} ${chalk.cyan(label)} ${url}${icon}`;
104
+ if (sched) line += ` ${chalk.yellow(sched)}`;
105
+ console.log(line);
106
+ });
107
+ });
108
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * links open — Open the deployed page in a browser.
3
+ */
4
+
5
+ import { resolve } from 'node:path';
6
+ import { existsSync } from 'node:fs';
7
+ import open from 'open';
8
+ import { findConfig, readConfig } from '../config.js';
9
+
10
+ export function registerOpen(program) {
11
+ program
12
+ .command('open')
13
+ .description('Open the deployed page in a browser')
14
+ .option('--local', 'Open the local dist/index.html instead of the live domain')
15
+ .action(async (options) => {
16
+ let configPath;
17
+ try {
18
+ configPath = findConfig();
19
+ } catch {
20
+ if (options.local) {
21
+ // Allow --local even without config
22
+ const localPath = resolve('dist', 'index.html');
23
+ if (!existsSync(localPath)) {
24
+ console.error(`Local file not found: ${localPath}`);
25
+ console.error('Run: links deploy --self');
26
+ process.exit(1);
27
+ }
28
+ await open(`file://${localPath}`);
29
+ return;
30
+ }
31
+ console.error('No links.yaml found. Run: links init');
32
+ process.exit(1);
33
+ }
34
+
35
+ if (options.local) {
36
+ const config = readConfig(configPath);
37
+ const outDir = resolve(configPath, '..', 'dist');
38
+ const localPath = resolve(outDir, 'index.html');
39
+
40
+ if (!existsSync(localPath)) {
41
+ console.error(`Local file not found: ${localPath}`);
42
+ console.error('Run: links deploy --self');
43
+ process.exit(1);
44
+ }
45
+
46
+ await open(`file://${localPath}`);
47
+ console.log(`Opened: file://${localPath}`);
48
+ return;
49
+ }
50
+
51
+ // Default: open domain
52
+ const config = readConfig(configPath);
53
+ const domain = config.domain?.trim();
54
+
55
+ if (!domain) {
56
+ console.error('No domain set. Use --local to preview, or set domain in links.yaml');
57
+ process.exit(1);
58
+ }
59
+
60
+ const url = domain.startsWith('http') ? domain : `https://${domain}`;
61
+ await open(url);
62
+ console.log(`Opened: ${url}`);
63
+ });
64
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * links qr — Generate a QR code for the link page URL.
3
+ *
4
+ * Usage:
5
+ * links qr [url] [options]
6
+ *
7
+ * If url is omitted, uses config.domain from links.yaml.
8
+ * --out <file> Save QR code as PNG to specified file path.
9
+ * --link <label> Generate QR for a specific link's URL instead of the page URL.
10
+ */
11
+
12
+ import QRCode from 'qrcode';
13
+ import chalk from 'chalk';
14
+ import { findConfig, readConfig } from '../config.js';
15
+
16
+ export function registerQr(program) {
17
+ program
18
+ .command('qr')
19
+ .description('Generate a QR code for the link page URL')
20
+ .argument('[url]', 'URL to encode (defaults to config.domain)')
21
+ .option('--out <file>', 'save QR code as PNG to specified file path')
22
+ .option('--link <label>', 'generate QR for a specific link by label')
23
+ .action(async (urlArg, options) => {
24
+ let url = urlArg;
25
+
26
+ // If --link is provided, look up the link's URL from config
27
+ if (options.link) {
28
+ let config;
29
+ try {
30
+ const configPath = findConfig();
31
+ config = readConfig(configPath);
32
+ } catch (err) {
33
+ console.error(chalk.red(err.message));
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+
38
+ const links = config.links || [];
39
+ const match = links.find(
40
+ (l) => l.label.toLowerCase() === options.link.toLowerCase(),
41
+ );
42
+
43
+ if (!match) {
44
+ console.error(
45
+ chalk.red(`Link with label "${options.link}" not found in links.yaml.`),
46
+ );
47
+ process.exitCode = 1;
48
+ return;
49
+ }
50
+
51
+ url = match.url;
52
+ }
53
+
54
+ // If no URL yet, fall back to config.domain
55
+ if (!url) {
56
+ try {
57
+ const configPath = findConfig();
58
+ const config = readConfig(configPath);
59
+
60
+ if (config.domain && config.domain.trim().length > 0) {
61
+ url = config.domain.trim();
62
+ // Ensure it has a protocol
63
+ if (!/^https?:\/\//i.test(url)) {
64
+ url = `https://${url}`;
65
+ }
66
+ }
67
+ } catch {
68
+ // config not found — will fall through to error below
69
+ }
70
+ }
71
+
72
+ if (!url) {
73
+ console.error(
74
+ chalk.red(
75
+ 'No URL specified and no domain set in links.yaml. Run: links qr <url>',
76
+ ),
77
+ );
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+
82
+ try {
83
+ if (options.out) {
84
+ // Save as PNG
85
+ await QRCode.toFile(options.out, url);
86
+ console.log(chalk.green(`QR code saved to ${options.out}`));
87
+ console.log(`URL: ${url}`);
88
+ } else {
89
+ // Render to terminal
90
+ const qrString = await QRCode.toString(url, { type: 'terminal' });
91
+ console.log(qrString);
92
+ console.log(`URL: ${url}`);
93
+ }
94
+ } catch (err) {
95
+ console.error(chalk.red(`Failed to generate QR code: ${err.message}`));
96
+ process.exitCode = 1;
97
+ }
98
+ });
99
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * links remove — Remove an existing link entry.
3
+ *
4
+ * Finds a link by label (case-insensitive), removes it from the config,
5
+ * and writes the updated file atomically.
6
+ */
7
+
8
+ import { findConfig, readConfig, writeConfig } from '../config.js';
9
+
10
+ export function registerRemove(program) {
11
+ program
12
+ .command('remove <label>')
13
+ .description('Remove a link')
14
+ .action(async (label) => {
15
+ // Find and read config
16
+ let configPath;
17
+ let config;
18
+ try {
19
+ configPath = findConfig();
20
+ config = readConfig(configPath);
21
+ } catch (err) {
22
+ console.error(`Error: ${err.message}`);
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+
27
+ const links = config.links || [];
28
+
29
+ // Find link by label (case-insensitive)
30
+ const index = links.findIndex(
31
+ (l) => l.label.toLowerCase() === label.toLowerCase(),
32
+ );
33
+
34
+ if (index === -1) {
35
+ console.error(`No link with label "${label}" found.`);
36
+ process.exitCode = 1;
37
+ return;
38
+ }
39
+
40
+ // Remove the link
41
+ const removed = links.splice(index, 1)[0];
42
+ config.links = links;
43
+
44
+ // Write back
45
+ try {
46
+ writeConfig(configPath, config);
47
+ } catch (err) {
48
+ console.error(`Error: could not write links.yaml — ${err.message}`);
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+
53
+ console.log(`✓ Removed: ${removed.label}`);
54
+ });
55
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * links status — Show current project status summary.
3
+ */
4
+
5
+ import { statSync } from 'node:fs';
6
+ import chalk from 'chalk';
7
+ import { findConfig, readConfig } from '../config.js';
8
+ import { isLinkActive } from '../utils.js';
9
+
10
+ export function registerStatus(program) {
11
+ program
12
+ .command('status')
13
+ .description('Show current project status')
14
+ .action(() => {
15
+ let configPath;
16
+ try {
17
+ configPath = findConfig();
18
+ } catch {
19
+ console.error('No links.yaml found. Run: links init');
20
+ process.exit(1);
21
+ }
22
+
23
+ const config = readConfig(configPath);
24
+ const links = config.links ?? [];
25
+
26
+ // Count scheduled (have scheduling fields) vs active
27
+ const scheduledLinks = links.filter(
28
+ (l) => l.scheduled_from || l.scheduled_until,
29
+ );
30
+ const activeLinks = links.filter((l) => isLinkActive(l));
31
+
32
+ // Last modified
33
+ let lastModified = 'unknown';
34
+ try {
35
+ const stat = statSync(configPath);
36
+ lastModified = stat.mtime.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
37
+ } catch {
38
+ // ignore
39
+ }
40
+
41
+ // Bio truncation
42
+ const bio = config.bio?.trim() || '';
43
+ const bioDisplay = bio.length > 60 ? bio.slice(0, 57) + '...' : bio || chalk.dim('not set');
44
+
45
+ // Domain
46
+ const domain = config.domain?.trim() || '';
47
+
48
+ // Output
49
+ console.log();
50
+ console.log(chalk.bold(' Links Project Status'));
51
+ console.log(chalk.dim(' ─────────────────────────────'));
52
+ console.log(` ${chalk.cyan('Name:')} ${config.name}`);
53
+ console.log(` ${chalk.cyan('Bio:')} ${bioDisplay}`);
54
+ console.log(` ${chalk.cyan('Theme:')} ${config.theme || 'minimal-dark'}`);
55
+ console.log(` ${chalk.cyan('Domain:')} ${domain || chalk.dim('not set')}`);
56
+ console.log(` ${chalk.cyan('Avatar:')} ${config.avatar ? chalk.green('set') : chalk.dim('not set')}`);
57
+ console.log(` ${chalk.cyan('Total links:')} ${links.length}`);
58
+ console.log(` ${chalk.cyan('Active links:')} ${activeLinks.length}`);
59
+ console.log(` ${chalk.cyan('Scheduled:')} ${scheduledLinks.length}`);
60
+ console.log(` ${chalk.cyan('Last modified:')} ${lastModified}`);
61
+ console.log(` ${chalk.cyan('Config:')} ${configPath}`);
62
+ console.log();
63
+ });
64
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * links theme — Switch and list themes.
3
+ *
4
+ * Subcommands:
5
+ * links theme list — list available themes (default)
6
+ * links theme set <name> — set the active theme in links.yaml
7
+ * links theme — alias for `links theme list`
8
+ */
9
+
10
+ import { findConfig, readConfig, writeConfig } from '../config.js';
11
+ import { listThemes } from '../themes/loader.js';
12
+
13
+ /**
14
+ * Print the list of available themes, marking the active one with ✓.
15
+ * @param {string} activeTheme — currently configured theme name
16
+ */
17
+ function printThemeList(activeTheme) {
18
+ const themes = listThemes();
19
+
20
+ if (themes.length === 0) {
21
+ console.log('No themes installed.');
22
+ return;
23
+ }
24
+
25
+ for (const name of themes) {
26
+ const marker = name === activeTheme ? ' ✓' : '';
27
+ console.log(` ${name}${marker}`);
28
+ }
29
+ }
30
+
31
+ export function registerTheme(program) {
32
+ const themeCmd = program
33
+ .command('theme')
34
+ .description('Switch and list themes')
35
+ .action(() => {
36
+ // `links theme` with no subcommand → default to list behaviour
37
+ try {
38
+ const configPath = findConfig();
39
+ const config = readConfig(configPath);
40
+ printThemeList(config.theme || 'minimal-dark');
41
+ } catch (err) {
42
+ console.error(err.message);
43
+ process.exit(1);
44
+ }
45
+ });
46
+
47
+ // --- links theme list ---------------------------------------------------
48
+ themeCmd
49
+ .command('list')
50
+ .description('List available themes')
51
+ .action(() => {
52
+ try {
53
+ const configPath = findConfig();
54
+ const config = readConfig(configPath);
55
+ printThemeList(config.theme || 'minimal-dark');
56
+ } catch (err) {
57
+ console.error(err.message);
58
+ process.exit(1);
59
+ }
60
+ });
61
+
62
+ // --- links theme set <name> ---------------------------------------------
63
+ themeCmd
64
+ .command('set <name>')
65
+ .description('Set the active theme')
66
+ .action((name) => {
67
+ try {
68
+ const available = listThemes();
69
+
70
+ if (!available.includes(name)) {
71
+ console.error(`Unknown theme: ${name}. Run: links theme list`);
72
+ process.exit(1);
73
+ }
74
+
75
+ const configPath = findConfig();
76
+ const config = readConfig(configPath);
77
+ config.theme = name;
78
+ writeConfig(configPath, config);
79
+
80
+ console.log(`✓ Theme set to: ${name}. Rebuild with: links deploy --self`);
81
+ } catch (err) {
82
+ console.error(err.message);
83
+ process.exit(1);
84
+ }
85
+ });
86
+ }
package/src/config.js ADDED
@@ -0,0 +1,221 @@
1
+ import { readFileSync, writeFileSync, renameSync } from 'node:fs';
2
+ import { resolve, dirname, join } from 'node:path';
3
+ import yaml from 'js-yaml';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Schema defaults
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export const DEFAULT_CONFIG = {
10
+ name: 'My Links',
11
+ bio: '',
12
+ avatar: '',
13
+ theme: 'minimal-dark',
14
+ domain: '',
15
+ links: [],
16
+ };
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Validation helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Validate that a URL string starts with http:// or https://.
24
+ * @param {string} url
25
+ * @returns {{ valid: boolean, error?: string }}
26
+ */
27
+ export function validateUrl(url) {
28
+ if (typeof url !== 'string' || url.length === 0) {
29
+ return { valid: false, error: 'url must be a non-empty string' };
30
+ }
31
+ if (!/^https?:\/\//i.test(url)) {
32
+ return { valid: false, error: `url must start with http:// or https:// — got "${url}"` };
33
+ }
34
+ return { valid: true };
35
+ }
36
+
37
+ /**
38
+ * Validate that a string is a valid ISO 8601 date (YYYY-MM-DD or full ISO 8601).
39
+ * @param {string} str
40
+ * @returns {{ valid: boolean, error?: string }}
41
+ */
42
+ export function validateDate(str) {
43
+ if (typeof str !== 'string' || str.length === 0) {
44
+ return { valid: false, error: 'date must be a non-empty string' };
45
+ }
46
+ if (Number.isNaN(Date.parse(str))) {
47
+ return { valid: false, error: `not a valid ISO 8601 date — got "${str}"` };
48
+ }
49
+ return { valid: true };
50
+ }
51
+
52
+ /**
53
+ * Validate a full config object against the links.yaml schema.
54
+ * Collects all errors instead of failing on the first one.
55
+ * @param {object} config
56
+ * @returns {{ valid: boolean, errors: string[] }}
57
+ */
58
+ export function validateConfig(config) {
59
+ const errors = [];
60
+
61
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
62
+ return { valid: false, errors: ['links.yaml: config must be a YAML mapping (object)'] };
63
+ }
64
+
65
+ // --- top-level required fields ---
66
+ if (typeof config.name !== 'string' || config.name.trim().length === 0) {
67
+ errors.push("links.yaml: missing required field 'name' (must be a non-empty string)");
68
+ }
69
+
70
+ // --- top-level optional string fields ---
71
+ for (const field of ['bio', 'avatar', 'domain']) {
72
+ if (config[field] !== undefined && config[field] !== null && typeof config[field] !== 'string') {
73
+ errors.push(`links.yaml: field '${field}' must be a string`);
74
+ }
75
+ }
76
+
77
+ // --- theme ---
78
+ if (config.theme !== undefined && config.theme !== null) {
79
+ if (typeof config.theme !== 'string' || config.theme.trim().length === 0) {
80
+ errors.push("links.yaml: field 'theme' must be a non-empty string");
81
+ }
82
+ }
83
+
84
+ // --- links array ---
85
+ if (config.links !== undefined && config.links !== null) {
86
+ if (!Array.isArray(config.links)) {
87
+ errors.push("links.yaml: field 'links' must be an array");
88
+ } else {
89
+ config.links.forEach((link, i) => {
90
+ const prefix = `links.yaml: links[${i}]`;
91
+
92
+ if (link === null || typeof link !== 'object' || Array.isArray(link)) {
93
+ errors.push(`${prefix} must be an object`);
94
+ return;
95
+ }
96
+
97
+ // required
98
+ if (typeof link.label !== 'string' || link.label.trim().length === 0) {
99
+ errors.push(`${prefix}: missing required field 'label' (must be a non-empty string)`);
100
+ }
101
+ if (typeof link.url !== 'string' || link.url.trim().length === 0) {
102
+ errors.push(`${prefix}: missing required field 'url' (must be a non-empty string)`);
103
+ } else {
104
+ const urlCheck = validateUrl(link.url);
105
+ if (!urlCheck.valid) {
106
+ errors.push(`${prefix}: ${urlCheck.error}`);
107
+ }
108
+ }
109
+
110
+ // optional strings
111
+ for (const field of ['icon', 'description']) {
112
+ if (link[field] !== undefined && link[field] !== null && typeof link[field] !== 'string') {
113
+ errors.push(`${prefix}: field '${field}' must be a string`);
114
+ }
115
+ }
116
+
117
+ // optional ISO date strings
118
+ for (const field of ['scheduled_from', 'scheduled_until']) {
119
+ if (link[field] !== undefined && link[field] !== null) {
120
+ if (typeof link[field] !== 'string') {
121
+ errors.push(`${prefix}: field '${field}' must be an ISO 8601 date string`);
122
+ } else {
123
+ const dateCheck = validateDate(link[field]);
124
+ if (!dateCheck.valid) {
125
+ errors.push(`${prefix}: field '${field}' — ${dateCheck.error}`);
126
+ }
127
+ }
128
+ }
129
+ }
130
+ });
131
+ }
132
+ }
133
+
134
+ return { valid: errors.length === 0, errors };
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Config file discovery
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /**
142
+ * Walk up from `startDir` (defaults to cwd) looking for links.yaml.
143
+ * @param {string} [startDir=process.cwd()]
144
+ * @returns {string} Absolute path to the nearest links.yaml.
145
+ * @throws {Error} If no links.yaml is found before reaching the filesystem root.
146
+ */
147
+ export function findConfig(startDir = process.cwd()) {
148
+ let dir = resolve(startDir);
149
+
150
+ // eslint-disable-next-line no-constant-condition
151
+ while (true) {
152
+ const candidate = join(dir, 'links.yaml');
153
+ try {
154
+ readFileSync(candidate); // existence check
155
+ return candidate;
156
+ } catch {
157
+ // not found — go up
158
+ }
159
+
160
+ const parent = dirname(dir);
161
+ if (parent === dir) {
162
+ throw new Error(
163
+ 'links.yaml not found. Run "links init" to create a new project.',
164
+ );
165
+ }
166
+ dir = parent;
167
+ }
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Read / Write
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Read, parse, and validate a links.yaml file.
176
+ * @param {string} filePath — absolute or relative path to links.yaml
177
+ * @returns {object} Parsed and validated config object.
178
+ * @throws {Error} On read failure, YAML parse failure, or validation failure.
179
+ */
180
+ export function readConfig(filePath) {
181
+ let raw;
182
+ try {
183
+ raw = readFileSync(resolve(filePath), 'utf8');
184
+ } catch (err) {
185
+ throw new Error(`links.yaml: unable to read file "${filePath}" — ${err.message}`);
186
+ }
187
+
188
+ let config;
189
+ try {
190
+ config = yaml.load(raw);
191
+ } catch (err) {
192
+ throw new Error(`links.yaml: YAML parse error — ${err.message}`);
193
+ }
194
+
195
+ const { valid, errors } = validateConfig(config);
196
+ if (!valid) {
197
+ throw new Error(errors.join('\n'));
198
+ }
199
+
200
+ return config;
201
+ }
202
+
203
+ /**
204
+ * Serialize a config object to YAML and write it atomically.
205
+ * Writes to a temporary file first, then renames into place.
206
+ * @param {string} filePath — absolute or relative path to links.yaml
207
+ * @param {object} config — config object to persist
208
+ */
209
+ export function writeConfig(filePath, config) {
210
+ const dest = resolve(filePath);
211
+ const tmp = `${dest}.tmp`;
212
+
213
+ const content = yaml.dump(config, {
214
+ lineWidth: 120,
215
+ noRefs: true,
216
+ sortKeys: false,
217
+ });
218
+
219
+ writeFileSync(tmp, content, 'utf8');
220
+ renameSync(tmp, dest);
221
+ }