@thegetty/quire-cli 1.0.0-rc.45 → 1.0.0-rc.47
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/CHANGELOG.md +0 -36
- package/README.md +0 -9
- package/bin/cli.js +1 -46
- package/package.json +10 -27
- package/src/Command.js +6 -26
- package/src/commands/README.md +122 -213
- package/src/commands/build.js +37 -45
- package/src/commands/clean.js +19 -33
- package/src/commands/conf.js +43 -0
- package/src/commands/create.js +14 -42
- package/src/commands/epub.js +31 -67
- package/src/commands/index.js +2 -9
- package/src/commands/info.js +86 -88
- package/src/commands/pdf.js +82 -63
- package/src/commands/preview.js +31 -32
- package/src/commands/validate.js +20 -64
- package/src/commands/version.js +43 -0
- package/src/errors/validation/validation-error.js +11 -20
- package/src/helpers/clean.js +1 -1
- package/src/helpers/is-quire.js +31 -0
- package/src/helpers/test-cwd.js +6 -5
- package/src/helpers/which.js +4 -10
- package/src/lib/11ty/README.md +19 -135
- package/src/lib/11ty/api.js +93 -235
- package/src/lib/11ty/cli.js +37 -112
- package/src/lib/11ty/index.js +5 -64
- package/src/lib/11ty/paths.js +103 -0
- package/src/lib/README.md +0 -330
- package/src/lib/conf/README.md +69 -86
- package/src/lib/conf/config.js +3 -5
- package/src/lib/conf/defaults.js +0 -65
- package/src/lib/conf/schema.js +9 -103
- package/src/lib/epub/README.md +2 -133
- package/src/lib/epub/epub.js +11 -36
- package/src/lib/epub/index.js +21 -111
- package/src/lib/epub/pandoc.js +4 -33
- package/src/lib/git/README.md +2 -151
- package/src/lib/git/index.js +13 -217
- package/src/lib/i18n/README.md +3 -0
- package/src/lib/i18n/config.js +53 -0
- package/src/lib/i18n/index.js +43 -0
- package/src/lib/i18n/localeService.js +58 -0
- package/src/lib/pdf/README.md +0 -131
- package/src/lib/pdf/index.js +21 -124
- package/src/lib/pdf/paged.js +52 -100
- package/src/lib/pdf/prince.js +37 -115
- package/src/lib/pdf/split.js +33 -61
- package/src/lib/quire/README.md +19 -0
- package/src/lib/quire/index.js +353 -0
- package/src/lib/reporter/README.md +2 -211
- package/src/lib/reporter/index.js +0 -607
- package/src/main.js +48 -221
- package/src/packageConfig.js +0 -17
- package/src/validators/utils.js +1 -2
- package/man/quire.1 +0 -341
- package/man/quire.1.md +0 -232
- package/schemas/config.schema.json +0 -194
- package/schemas/figures.schema.json +0 -56
- package/schemas/layout.schema.json +0 -5
- package/schemas/objects.schema.json +0 -62
- package/schemas/publication.schema.json +0 -140
- package/schemas/references.schema.json +0 -29
- package/src/Command.spec.js +0 -99
- package/src/commands/build.spec.js +0 -113
- package/src/commands/build.test.js +0 -397
- package/src/commands/clean.spec.js +0 -118
- package/src/commands/clean.test.js +0 -382
- package/src/commands/config.js +0 -238
- package/src/commands/config.spec.js +0 -107
- package/src/commands/config.test.js +0 -845
- package/src/commands/create.spec.js +0 -112
- package/src/commands/create.test.js +0 -415
- package/src/commands/doctor.js +0 -251
- package/src/commands/doctor.spec.js +0 -115
- package/src/commands/doctor.test.js +0 -1409
- package/src/commands/epub.spec.js +0 -114
- package/src/commands/epub.test.js +0 -503
- package/src/commands/help.js +0 -60
- package/src/commands/help.test.js +0 -132
- package/src/commands/info.spec.js +0 -72
- package/src/commands/info.test.js +0 -512
- package/src/commands/pdf.spec.js +0 -114
- package/src/commands/pdf.test.js +0 -464
- package/src/commands/preview.spec.js +0 -97
- package/src/commands/preview.test.js +0 -250
- package/src/commands/use.js +0 -56
- package/src/commands/use.spec.js +0 -61
- package/src/commands/use.test.js +0 -280
- package/src/commands/validate.spec.js +0 -90
- package/src/commands/validate.test.js +0 -378
- package/src/commands/workflows.js +0 -70
- package/src/errors/build/build-failed-error.js +0 -19
- package/src/errors/build/config-field-missing-error.js +0 -20
- package/src/errors/build/config-file-not-found-error.js +0 -20
- package/src/errors/build/index.js +0 -11
- package/src/errors/help/help-topic-not-found-error.js +0 -30
- package/src/errors/help/index.js +0 -8
- package/src/errors/index.js +0 -58
- package/src/errors/input/index.js +0 -8
- package/src/errors/input/invalid-input-error.js +0 -21
- package/src/errors/install/dependency-install-error.js +0 -19
- package/src/errors/install/directory-not-empty-error.js +0 -25
- package/src/errors/install/index.js +0 -12
- package/src/errors/install/invalid-path-error.js +0 -31
- package/src/errors/install/invalid-starter-error.js +0 -27
- package/src/errors/install/version-not-found-error.js +0 -35
- package/src/errors/output/epub-generation-error.js +0 -19
- package/src/errors/output/index.js +0 -14
- package/src/errors/output/invalid-epub-library-error.js +0 -20
- package/src/errors/output/invalid-pdf-library-error.js +0 -20
- package/src/errors/output/missing-build-output-error.js +0 -21
- package/src/errors/output/pdf-generation-error.js +0 -24
- package/src/errors/output/tool-not-found-error.js +0 -37
- package/src/errors/project/index.js +0 -10
- package/src/errors/project/not-in-project-error.js +0 -20
- package/src/errors/project/project-create-error.js +0 -21
- package/src/errors/quire-error.js +0 -27
- package/src/errors/validation/index.js +0 -3
- package/src/errors/validation/unknown-config-key-error.js +0 -27
- package/src/errors/validation/unknown-config-operation-error.js +0 -24
- package/src/helpers/docs-url.js +0 -32
- package/src/helpers/pager.js +0 -59
- package/src/helpers/pager.test.js +0 -85
- package/src/helpers/suggest-similar.js +0 -124
- package/src/helpers/suggest-similar.test.js +0 -108
- package/src/helpers/test-cwd.test.js +0 -192
- package/src/lib/11ty/index.test.js +0 -655
- package/src/lib/commander/index.js +0 -103
- package/src/lib/commander/index.test.js +0 -86
- package/src/lib/commander/options.js +0 -237
- package/src/lib/commander/options.test.js +0 -205
- package/src/lib/conf/build-status.js +0 -103
- package/src/lib/conf/build-status.test.js +0 -247
- package/src/lib/conf/config.test.js +0 -281
- package/src/lib/conf/format.js +0 -60
- package/src/lib/conf/format.test.js +0 -106
- package/src/lib/conf/helpers.js +0 -91
- package/src/lib/conf/helpers.test.js +0 -136
- package/src/lib/conf/index.js +0 -22
- package/src/lib/constants.js +0 -28
- package/src/lib/doctor/README.md +0 -667
- package/src/lib/doctor/checks/environment/cli-version.js +0 -62
- package/src/lib/doctor/checks/environment/cli-version.test.js +0 -132
- package/src/lib/doctor/checks/environment/git-available.js +0 -77
- package/src/lib/doctor/checks/environment/git-available.test.js +0 -52
- package/src/lib/doctor/checks/environment/index.js +0 -13
- package/src/lib/doctor/checks/environment/node-version.js +0 -66
- package/src/lib/doctor/checks/environment/node-version.test.js +0 -17
- package/src/lib/doctor/checks/environment/npm-available.js +0 -66
- package/src/lib/doctor/checks/environment/npm-available.test.js +0 -52
- package/src/lib/doctor/checks/environment/os-info.js +0 -48
- package/src/lib/doctor/checks/environment/os-info.test.js +0 -81
- package/src/lib/doctor/checks/environment/runtime-info.js +0 -51
- package/src/lib/doctor/checks/environment/runtime-info.test.js +0 -132
- package/src/lib/doctor/checks/outputs/epub-output.js +0 -119
- package/src/lib/doctor/checks/outputs/epub-output.test.js +0 -277
- package/src/lib/doctor/checks/outputs/index.js +0 -10
- package/src/lib/doctor/checks/outputs/pdf-output.js +0 -144
- package/src/lib/doctor/checks/outputs/pdf-output.test.js +0 -377
- package/src/lib/doctor/checks/outputs/stale-build.js +0 -122
- package/src/lib/doctor/checks/outputs/stale-build.test.js +0 -282
- package/src/lib/doctor/checks/project/data-files.js +0 -56
- package/src/lib/doctor/checks/project/data-files.test.js +0 -125
- package/src/lib/doctor/checks/project/dependencies.js +0 -53
- package/src/lib/doctor/checks/project/dependencies.test.js +0 -71
- package/src/lib/doctor/checks/project/index.js +0 -11
- package/src/lib/doctor/checks/project/quire-11ty.js +0 -98
- package/src/lib/doctor/checks/project/quire-11ty.test.js +0 -170
- package/src/lib/doctor/checks/project/quire-project.js +0 -38
- package/src/lib/doctor/checks/project/quire-project.test.js +0 -47
- package/src/lib/doctor/checks/tools/index.js +0 -10
- package/src/lib/doctor/checks/tools/pandoc-available.js +0 -82
- package/src/lib/doctor/checks/tools/pandoc-available.test.js +0 -73
- package/src/lib/doctor/checks/tools/prince-available.js +0 -81
- package/src/lib/doctor/checks/tools/prince-available.test.js +0 -73
- package/src/lib/doctor/constants.js +0 -39
- package/src/lib/doctor/formatDuration.js +0 -108
- package/src/lib/doctor/formatDuration.test.js +0 -76
- package/src/lib/doctor/formatters/human.js +0 -257
- package/src/lib/doctor/formatters/human.test.js +0 -463
- package/src/lib/doctor/formatters/index.js +0 -8
- package/src/lib/doctor/formatters/json.js +0 -78
- package/src/lib/doctor/formatters/json.test.js +0 -174
- package/src/lib/doctor/formatters/shared.js +0 -129
- package/src/lib/doctor/formatters/shared.test.js +0 -194
- package/src/lib/doctor/index.js +0 -271
- package/src/lib/doctor/index.test.js +0 -797
- package/src/lib/epub/engines.js +0 -46
- package/src/lib/epub/index.test.js +0 -518
- package/src/lib/epub/pandoc.test.js +0 -122
- package/src/lib/epub/schema.js +0 -21
- package/src/lib/error/README.md +0 -170
- package/src/lib/error/handler.js +0 -107
- package/src/lib/git/index.spec.js +0 -80
- package/src/lib/git/index.test.js +0 -453
- package/src/lib/help/frontmatter.js +0 -77
- package/src/lib/help/frontmatter.test.js +0 -139
- package/src/lib/help/index.js +0 -146
- package/src/lib/help/index.test.js +0 -188
- package/src/lib/help/topics/configuration.md +0 -77
- package/src/lib/help/topics/debugging.md +0 -69
- package/src/lib/help/topics/epub.md +0 -74
- package/src/lib/help/topics/pdf.md +0 -74
- package/src/lib/help/topics/publishing.md +0 -80
- package/src/lib/help/topics/workflows.md +0 -50
- package/src/lib/installer/index.js +0 -309
- package/src/lib/installer/index.spec.js +0 -83
- package/src/lib/installer/index.test.js +0 -545
- package/src/lib/logger/README.md +0 -424
- package/src/lib/logger/debug.js +0 -132
- package/src/lib/logger/debug.spec.js +0 -130
- package/src/lib/logger/debug.test.js +0 -128
- package/src/lib/logger/index.js +0 -228
- package/src/lib/logger/index.spec.js +0 -131
- package/src/lib/logger/index.test.js +0 -578
- package/src/lib/npm/README.md +0 -127
- package/src/lib/npm/index.js +0 -198
- package/src/lib/npm/index.spec.js +0 -60
- package/src/lib/npm/index.test.js +0 -355
- package/src/lib/pdf/engines.js +0 -46
- package/src/lib/pdf/index.test.js +0 -708
- package/src/lib/pdf/paged.test.js +0 -366
- package/src/lib/pdf/prince.test.js +0 -202
- package/src/lib/pdf/schema.js +0 -21
- package/src/lib/pdf/split.test.js +0 -445
- package/src/lib/platform.js +0 -95
- package/src/lib/process/manager.js +0 -110
- package/src/lib/process/manager.test.js +0 -55
- package/src/lib/project/build.js +0 -162
- package/src/lib/project/build.test.js +0 -275
- package/src/lib/project/config.js +0 -48
- package/src/lib/project/config.test.js +0 -134
- package/src/lib/project/detect.js +0 -47
- package/src/lib/project/detect.test.js +0 -157
- package/src/lib/project/index.js +0 -56
- package/src/lib/project/output-paths.js +0 -87
- package/src/lib/project/output-paths.test.js +0 -66
- package/src/lib/project/paths.js +0 -272
- package/src/lib/project/version.js +0 -110
- package/src/lib/project/version.test.js +0 -350
- package/src/lib/reporter/index.test.js +0 -862
- package/src/main.spec.js +0 -61
- package/src/main.test.js +0 -347
- package/src/validators/validate-data-files.js +0 -154
- package/src/validators/validate-data-files.test.js +0 -217
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-project build status persistence
|
|
3
|
-
*
|
|
4
|
-
* Records command outcomes (ok/failed) for build, pdf, and epub commands
|
|
5
|
-
* so that `quire doctor` can distinguish "never ran" from "ran and failed"
|
|
6
|
-
* when no output files exist on disk.
|
|
7
|
-
*
|
|
8
|
-
* Status is stored in the global CLI config under the `projects` key,
|
|
9
|
-
* keyed by a SHA-256 hash of the project's absolute path to avoid
|
|
10
|
-
* dot-notation traversal issues.
|
|
11
|
-
*
|
|
12
|
-
* @module lib/conf/build-status
|
|
13
|
-
*/
|
|
14
|
-
import { createHash } from 'node:crypto'
|
|
15
|
-
import config from '#lib/conf/config.js'
|
|
16
|
-
import createDebug from '#debug'
|
|
17
|
-
|
|
18
|
-
const debug = createDebug('lib:conf:build-status')
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Commands whose status is tracked
|
|
22
|
-
* @type {readonly string[]}
|
|
23
|
-
*/
|
|
24
|
-
export const TRACKED_COMMANDS = Object.freeze(['build', 'pdf', 'epub'])
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Derive a fixed-length, dot-safe config key from a project path
|
|
28
|
-
*
|
|
29
|
-
* @param {string} projectPath - Absolute path to the project directory
|
|
30
|
-
* @returns {string} First 12 hex characters of the SHA-256 hash
|
|
31
|
-
*/
|
|
32
|
-
export function projectKey(projectPath) {
|
|
33
|
-
return createHash('sha256').update(projectPath).digest('hex').slice(0, 12)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Record the outcome of a command for a project
|
|
38
|
-
*
|
|
39
|
-
* @param {string} projectPath - Absolute path to the project directory
|
|
40
|
-
* @param {string} command - Command name ('build', 'pdf', or 'epub')
|
|
41
|
-
* @param {'ok'|'failed'} status - Outcome of the command
|
|
42
|
-
*/
|
|
43
|
-
export function recordStatus(projectPath, command, status) {
|
|
44
|
-
if (!TRACKED_COMMANDS.includes(command)) {
|
|
45
|
-
debug('ignoring untracked command: %s', command)
|
|
46
|
-
return
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const key = projectKey(projectPath)
|
|
50
|
-
const projects = config.get('projects') || {}
|
|
51
|
-
|
|
52
|
-
const entry = projects[key] || { projectPath }
|
|
53
|
-
entry.projectPath = projectPath
|
|
54
|
-
entry.buildStatus = {
|
|
55
|
-
...entry.buildStatus,
|
|
56
|
-
[command]: {
|
|
57
|
-
status,
|
|
58
|
-
timestamp: Date.now(),
|
|
59
|
-
},
|
|
60
|
-
}
|
|
61
|
-
projects[key] = entry
|
|
62
|
-
|
|
63
|
-
config.set('projects', projects)
|
|
64
|
-
debug('recorded %s=%s for %s (key=%s)', command, status, projectPath, key)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Retrieve the stored status for a command in a project
|
|
69
|
-
*
|
|
70
|
-
* @param {string} projectPath - Absolute path to the project directory
|
|
71
|
-
* @param {string} command - Command name ('build', 'pdf', or 'epub')
|
|
72
|
-
* @returns {{ status: 'ok'|'failed', timestamp: number } | undefined}
|
|
73
|
-
*/
|
|
74
|
-
export function getStatus(projectPath, command) {
|
|
75
|
-
const key = projectKey(projectPath)
|
|
76
|
-
const projects = config.get('projects') || {}
|
|
77
|
-
const entry = projects[key]
|
|
78
|
-
|
|
79
|
-
if (!entry || !entry.buildStatus) {
|
|
80
|
-
debug('no status entry for key=%s', key)
|
|
81
|
-
return undefined
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return entry.buildStatus[command]
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Clear all stored status for a project
|
|
89
|
-
*
|
|
90
|
-
* @param {string} projectPath - Absolute path to the project directory
|
|
91
|
-
*/
|
|
92
|
-
export function clearStatus(projectPath) {
|
|
93
|
-
const key = projectKey(projectPath)
|
|
94
|
-
const projects = config.get('projects') || {}
|
|
95
|
-
const entry = projects[key]
|
|
96
|
-
|
|
97
|
-
if (entry && entry.buildStatus) {
|
|
98
|
-
delete entry.buildStatus
|
|
99
|
-
projects[key] = entry
|
|
100
|
-
config.set('projects', projects)
|
|
101
|
-
debug('cleared build status for %s (key=%s)', projectPath, key)
|
|
102
|
-
}
|
|
103
|
-
}
|
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
import esmock from 'esmock'
|
|
2
|
-
import sinon from 'sinon'
|
|
3
|
-
import test from 'ava'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Build Status Module Tests
|
|
7
|
-
*
|
|
8
|
-
* Tests the per-project build status persistence API.
|
|
9
|
-
* Mocks the config module with a simple in-memory store.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
test.beforeEach(async (t) => {
|
|
13
|
-
const sandbox = sinon.createSandbox()
|
|
14
|
-
|
|
15
|
-
// In-memory store that mimics config.get/set for 'projects'
|
|
16
|
-
const store = {}
|
|
17
|
-
const configMock = {
|
|
18
|
-
default: {
|
|
19
|
-
get: sandbox.stub().callsFake((key) => store[key]),
|
|
20
|
-
set: sandbox.stub().callsFake((key, value) => { store[key] = value }),
|
|
21
|
-
},
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const mod = await esmock('./build-status.js', {
|
|
25
|
-
'#lib/conf/config.js': configMock,
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
t.context = { sandbox, store, configMock, ...mod }
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
test.afterEach.always((t) => {
|
|
32
|
-
t.context.sandbox.restore()
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
-
// projectKey
|
|
37
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
test('projectKey returns a 12-character hex string', (t) => {
|
|
40
|
-
const { projectKey } = t.context
|
|
41
|
-
const key = projectKey('/Users/test/my-project')
|
|
42
|
-
t.is(key.length, 12)
|
|
43
|
-
t.regex(key, /^[0-9a-f]{12}$/)
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
test('projectKey returns the same key for the same path', (t) => {
|
|
47
|
-
const { projectKey } = t.context
|
|
48
|
-
const key1 = projectKey('/Users/test/my-project')
|
|
49
|
-
const key2 = projectKey('/Users/test/my-project')
|
|
50
|
-
t.is(key1, key2)
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
test('projectKey returns different keys for different paths', (t) => {
|
|
54
|
-
const { projectKey } = t.context
|
|
55
|
-
const key1 = projectKey('/Users/test/project-a')
|
|
56
|
-
const key2 = projectKey('/Users/test/project-b')
|
|
57
|
-
t.not(key1, key2)
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
61
|
-
// recordStatus
|
|
62
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
test('recordStatus writes status with timestamp to config', (t) => {
|
|
65
|
-
const { recordStatus, projectKey, configMock } = t.context
|
|
66
|
-
const projectPath = '/Users/test/my-project'
|
|
67
|
-
|
|
68
|
-
recordStatus(projectPath, 'build', 'ok')
|
|
69
|
-
|
|
70
|
-
t.true(configMock.default.set.calledOnce)
|
|
71
|
-
const [setKey, setValue] = configMock.default.set.firstCall.args
|
|
72
|
-
t.is(setKey, 'projects')
|
|
73
|
-
|
|
74
|
-
const key = projectKey(projectPath)
|
|
75
|
-
const entry = setValue[key]
|
|
76
|
-
t.is(entry.projectPath, projectPath)
|
|
77
|
-
t.is(entry.buildStatus.build.status, 'ok')
|
|
78
|
-
t.is(typeof entry.buildStatus.build.timestamp, 'number')
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
test('recordStatus records failed status', (t) => {
|
|
82
|
-
const { recordStatus, projectKey, configMock } = t.context
|
|
83
|
-
const projectPath = '/Users/test/my-project'
|
|
84
|
-
|
|
85
|
-
recordStatus(projectPath, 'pdf', 'failed')
|
|
86
|
-
|
|
87
|
-
const [, setValue] = configMock.default.set.firstCall.args
|
|
88
|
-
const key = projectKey(projectPath)
|
|
89
|
-
t.is(setValue[key].buildStatus.pdf.status, 'failed')
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
test('recordStatus silently ignores untracked commands', (t) => {
|
|
93
|
-
const { recordStatus, configMock } = t.context
|
|
94
|
-
|
|
95
|
-
recordStatus('/Users/test/my-project', 'preview', 'ok')
|
|
96
|
-
|
|
97
|
-
t.false(configMock.default.set.called)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
test('recordStatus preserves existing command entries', (t) => {
|
|
101
|
-
const { recordStatus, projectKey, store } = t.context
|
|
102
|
-
const projectPath = '/Users/test/my-project'
|
|
103
|
-
const key = projectKey(projectPath)
|
|
104
|
-
|
|
105
|
-
// Pre-populate with a build entry
|
|
106
|
-
store.projects = {
|
|
107
|
-
[key]: {
|
|
108
|
-
projectPath,
|
|
109
|
-
buildStatus: {
|
|
110
|
-
build: { status: 'ok', timestamp: 1000 },
|
|
111
|
-
},
|
|
112
|
-
},
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
recordStatus(projectPath, 'pdf', 'failed')
|
|
116
|
-
|
|
117
|
-
const entry = store.projects[key]
|
|
118
|
-
t.is(entry.buildStatus.build.status, 'ok')
|
|
119
|
-
t.is(entry.buildStatus.build.timestamp, 1000)
|
|
120
|
-
t.is(entry.buildStatus.pdf.status, 'failed')
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
124
|
-
// getStatus
|
|
125
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
126
|
-
|
|
127
|
-
test('getStatus returns stored status entry', (t) => {
|
|
128
|
-
const { getStatus, projectKey, store } = t.context
|
|
129
|
-
const projectPath = '/Users/test/my-project'
|
|
130
|
-
const key = projectKey(projectPath)
|
|
131
|
-
|
|
132
|
-
store.projects = {
|
|
133
|
-
[key]: {
|
|
134
|
-
projectPath,
|
|
135
|
-
buildStatus: {
|
|
136
|
-
build: { status: 'ok', timestamp: 1000 },
|
|
137
|
-
},
|
|
138
|
-
},
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const result = getStatus(projectPath, 'build')
|
|
142
|
-
t.deepEqual(result, { status: 'ok', timestamp: 1000 })
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
test('getStatus returns undefined for unknown project', (t) => {
|
|
146
|
-
const { getStatus } = t.context
|
|
147
|
-
|
|
148
|
-
const result = getStatus('/Users/test/unknown-project', 'build')
|
|
149
|
-
t.is(result, undefined)
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
test('getStatus returns undefined for unrecorded command', (t) => {
|
|
153
|
-
const { getStatus, projectKey, store } = t.context
|
|
154
|
-
const projectPath = '/Users/test/my-project'
|
|
155
|
-
const key = projectKey(projectPath)
|
|
156
|
-
|
|
157
|
-
store.projects = {
|
|
158
|
-
[key]: {
|
|
159
|
-
projectPath,
|
|
160
|
-
buildStatus: {
|
|
161
|
-
build: { status: 'ok', timestamp: 1000 },
|
|
162
|
-
},
|
|
163
|
-
},
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const result = getStatus(projectPath, 'epub')
|
|
167
|
-
t.is(result, undefined)
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
171
|
-
// clearStatus
|
|
172
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
173
|
-
|
|
174
|
-
test('clearStatus removes project entry', (t) => {
|
|
175
|
-
const { clearStatus, projectKey, store, configMock } = t.context
|
|
176
|
-
const projectPath = '/Users/test/my-project'
|
|
177
|
-
const key = projectKey(projectPath)
|
|
178
|
-
|
|
179
|
-
store.projects = {
|
|
180
|
-
[key]: {
|
|
181
|
-
projectPath,
|
|
182
|
-
buildStatus: {
|
|
183
|
-
build: { status: 'ok', timestamp: 1000 },
|
|
184
|
-
},
|
|
185
|
-
},
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
clearStatus(projectPath)
|
|
189
|
-
|
|
190
|
-
const [, setValue] = configMock.default.set.firstCall.args
|
|
191
|
-
t.truthy(setValue[key], 'project entry should still exist')
|
|
192
|
-
t.is(setValue[key].projectPath, projectPath, 'projectPath should be preserved')
|
|
193
|
-
t.is(setValue[key].buildStatus, undefined, 'buildStatus should be removed')
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
test('clearStatus is a no-op for unknown project', (t) => {
|
|
197
|
-
const { clearStatus, configMock, store } = t.context
|
|
198
|
-
|
|
199
|
-
store.projects = {}
|
|
200
|
-
clearStatus('/Users/test/unknown-project')
|
|
201
|
-
|
|
202
|
-
t.false(configMock.default.set.called)
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
test('clearStatus is a no-op when project has no buildStatus', (t) => {
|
|
206
|
-
const { clearStatus, projectKey, configMock, store } = t.context
|
|
207
|
-
const projectPath = '/Users/test/my-project'
|
|
208
|
-
const key = projectKey(projectPath)
|
|
209
|
-
|
|
210
|
-
store.projects = {
|
|
211
|
-
[key]: { projectPath },
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
clearStatus(projectPath)
|
|
215
|
-
|
|
216
|
-
t.false(configMock.default.set.called)
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
220
|
-
// Multiple projects
|
|
221
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
222
|
-
|
|
223
|
-
test('multiple projects are tracked independently', (t) => {
|
|
224
|
-
const { recordStatus, getStatus } = t.context
|
|
225
|
-
const projectA = '/Users/test/project-a'
|
|
226
|
-
const projectB = '/Users/test/project-b'
|
|
227
|
-
|
|
228
|
-
recordStatus(projectA, 'build', 'ok')
|
|
229
|
-
recordStatus(projectB, 'build', 'failed')
|
|
230
|
-
|
|
231
|
-
t.is(getStatus(projectA, 'build').status, 'ok')
|
|
232
|
-
t.is(getStatus(projectB, 'build').status, 'failed')
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
236
|
-
// TRACKED_COMMANDS
|
|
237
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
238
|
-
|
|
239
|
-
test('TRACKED_COMMANDS contains build, pdf, and epub', (t) => {
|
|
240
|
-
const { TRACKED_COMMANDS } = t.context
|
|
241
|
-
t.deepEqual([...TRACKED_COMMANDS], ['build', 'pdf', 'epub'])
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
test('TRACKED_COMMANDS is frozen', (t) => {
|
|
245
|
-
const { TRACKED_COMMANDS } = t.context
|
|
246
|
-
t.true(Object.isFrozen(TRACKED_COMMANDS))
|
|
247
|
-
})
|
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
import esmock from 'esmock'
|
|
2
|
-
import sinon from 'sinon'
|
|
3
|
-
import test from 'ava'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Configuration Module Integration Tests
|
|
7
|
-
*
|
|
8
|
-
* Tests the Quire CLI configuration module behavior with mocked dependencies.
|
|
9
|
-
* Uses esmock to isolate the module from the actual Conf library and filesystem.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
test.beforeEach((t) => {
|
|
13
|
-
t.context.sandbox = sinon.createSandbox()
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
test.afterEach.always((t) => {
|
|
17
|
-
t.context.sandbox.restore()
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
-
// Configuration instantiation tests
|
|
22
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
test('config is created with correct project name and version', async (t) => {
|
|
25
|
-
const { sandbox } = t.context
|
|
26
|
-
|
|
27
|
-
let capturedOptions = null
|
|
28
|
-
const MockConf = sandbox.stub().callsFake(function (options) {
|
|
29
|
-
capturedOptions = options
|
|
30
|
-
return {
|
|
31
|
-
get: sandbox.stub(),
|
|
32
|
-
set: sandbox.stub()
|
|
33
|
-
}
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
await esmock('./config.js', {
|
|
37
|
-
conf: { default: MockConf },
|
|
38
|
-
'#src/packageConfig.js': { name: '@thegetty/quire-cli', version: '1.0.0' }
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
t.true(MockConf.calledOnce)
|
|
42
|
-
t.is(capturedOptions.projectName, '@thegetty/quire-cli')
|
|
43
|
-
t.is(capturedOptions.projectVersion, '1.0.0')
|
|
44
|
-
t.is(capturedOptions.projectSuffix, '')
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test('config is created with correct options', async (t) => {
|
|
48
|
-
const { sandbox } = t.context
|
|
49
|
-
|
|
50
|
-
let capturedOptions = null
|
|
51
|
-
const MockConf = sandbox.stub().callsFake(function (options) {
|
|
52
|
-
capturedOptions = options
|
|
53
|
-
return {
|
|
54
|
-
get: sandbox.stub(),
|
|
55
|
-
set: sandbox.stub()
|
|
56
|
-
}
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
await esmock('./config.js', {
|
|
60
|
-
conf: { default: MockConf },
|
|
61
|
-
'#src/packageConfig.js': { name: 'test', version: '1.0.0' }
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
t.true(capturedOptions.clearInvalidConfig)
|
|
65
|
-
t.true(capturedOptions.watch)
|
|
66
|
-
t.is(typeof capturedOptions.beforeEachMigration, 'function')
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
test('config passes defaults to Conf constructor', async (t) => {
|
|
70
|
-
const { sandbox } = t.context
|
|
71
|
-
|
|
72
|
-
let capturedOptions = null
|
|
73
|
-
const MockConf = sandbox.stub().callsFake(function (options) {
|
|
74
|
-
capturedOptions = options
|
|
75
|
-
return {
|
|
76
|
-
get: sandbox.stub(),
|
|
77
|
-
set: sandbox.stub()
|
|
78
|
-
}
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
const mockDefaults = {
|
|
82
|
-
logLevel: 'debug',
|
|
83
|
-
quireVersion: '2.0.0'
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
await esmock('./config.js', {
|
|
87
|
-
conf: { default: MockConf },
|
|
88
|
-
'./defaults.js': { default: mockDefaults },
|
|
89
|
-
'#src/packageConfig.js': { name: 'test', version: '1.0.0' }
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
t.deepEqual(capturedOptions.defaults, mockDefaults)
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
test('config passes schema to Conf constructor', async (t) => {
|
|
96
|
-
const { sandbox } = t.context
|
|
97
|
-
|
|
98
|
-
let capturedOptions = null
|
|
99
|
-
const MockConf = sandbox.stub().callsFake(function (options) {
|
|
100
|
-
capturedOptions = options
|
|
101
|
-
return {
|
|
102
|
-
get: sandbox.stub(),
|
|
103
|
-
set: sandbox.stub()
|
|
104
|
-
}
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
const mockSchema = {
|
|
108
|
-
logLevel: { type: 'string' },
|
|
109
|
-
quireVersion: { type: 'string' }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
await esmock('./config.js', {
|
|
113
|
-
conf: { default: MockConf },
|
|
114
|
-
'./schema.js': { default: mockSchema },
|
|
115
|
-
'#src/packageConfig.js': { name: 'test', version: '1.0.0' }
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
t.deepEqual(capturedOptions.schema, mockSchema)
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
test('config passes migrations to Conf constructor', async (t) => {
|
|
122
|
-
const { sandbox } = t.context
|
|
123
|
-
|
|
124
|
-
let capturedOptions = null
|
|
125
|
-
const MockConf = sandbox.stub().callsFake(function (options) {
|
|
126
|
-
capturedOptions = options
|
|
127
|
-
return {
|
|
128
|
-
get: sandbox.stub(),
|
|
129
|
-
set: sandbox.stub()
|
|
130
|
-
}
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
const mockMigrations = {
|
|
134
|
-
'1.0.0': () => {},
|
|
135
|
-
'2.0.0': () => {}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
await esmock('./config.js', {
|
|
139
|
-
conf: { default: MockConf },
|
|
140
|
-
'./migrations.js': { default: mockMigrations },
|
|
141
|
-
'#src/packageConfig.js': { name: 'test', version: '1.0.0' }
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
t.deepEqual(capturedOptions.migrations, mockMigrations)
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
148
|
-
// Migration callback tests
|
|
149
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
150
|
-
|
|
151
|
-
test.serial('beforeEachMigration logs migration info', async (t) => {
|
|
152
|
-
const { sandbox } = t.context
|
|
153
|
-
|
|
154
|
-
let capturedOptions = null
|
|
155
|
-
const MockConf = sandbox.stub().callsFake(function (options) {
|
|
156
|
-
capturedOptions = options
|
|
157
|
-
return {
|
|
158
|
-
get: sandbox.stub(),
|
|
159
|
-
set: sandbox.stub()
|
|
160
|
-
}
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
const consoleInfoStub = sandbox.stub(console, 'info')
|
|
164
|
-
|
|
165
|
-
await esmock('./config.js', {
|
|
166
|
-
conf: { default: MockConf },
|
|
167
|
-
'#src/packageConfig.js': { name: 'test', version: '1.0.0' }
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
// Call the beforeEachMigration callback
|
|
171
|
-
const mockStore = { get: sandbox.stub(), set: sandbox.stub() }
|
|
172
|
-
const mockContext = { fromVersion: '0.9.0', toVersion: '1.0.0' }
|
|
173
|
-
capturedOptions.beforeEachMigration(mockStore, mockContext)
|
|
174
|
-
|
|
175
|
-
t.true(consoleInfoStub.calledOnce)
|
|
176
|
-
t.true(consoleInfoStub.firstCall.args[0].includes('0.9.0'))
|
|
177
|
-
t.true(consoleInfoStub.firstCall.args[0].includes('1.0.0'))
|
|
178
|
-
t.true(consoleInfoStub.firstCall.args[0].includes('Migrating'))
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
182
|
-
// Module export tests
|
|
183
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
184
|
-
|
|
185
|
-
test('config module exports Conf instance as default', async (t) => {
|
|
186
|
-
const { sandbox } = t.context
|
|
187
|
-
|
|
188
|
-
const mockInstance = {
|
|
189
|
-
get: sandbox.stub().returns('value'),
|
|
190
|
-
set: sandbox.stub(),
|
|
191
|
-
has: sandbox.stub().returns(true),
|
|
192
|
-
delete: sandbox.stub()
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const MockConf = sandbox.stub().returns(mockInstance)
|
|
196
|
-
|
|
197
|
-
const config = await esmock('./config.js', {
|
|
198
|
-
conf: { default: MockConf },
|
|
199
|
-
'#src/packageConfig.js': { name: 'test', version: '1.0.0' }
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
t.is(config.default, mockInstance)
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
206
|
-
// Default values tests
|
|
207
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
208
|
-
|
|
209
|
-
test('defaults module exports expected configuration keys', async (t) => {
|
|
210
|
-
const defaults = await import('./defaults.js')
|
|
211
|
-
|
|
212
|
-
t.true('logLevel' in defaults.default)
|
|
213
|
-
t.true('projectTemplate' in defaults.default)
|
|
214
|
-
t.true('quire11tyPath' in defaults.default)
|
|
215
|
-
t.true('quireVersion' in defaults.default)
|
|
216
|
-
t.true('updateChannel' in defaults.default)
|
|
217
|
-
t.true('updateInterval' in defaults.default)
|
|
218
|
-
t.true('versionFile' in defaults.default)
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
test('defaults module has reasonable default values', async (t) => {
|
|
222
|
-
const defaults = await import('./defaults.js')
|
|
223
|
-
|
|
224
|
-
t.is(defaults.default.logLevel, 'info')
|
|
225
|
-
t.is(defaults.default.quireVersion, 'latest')
|
|
226
|
-
t.is(defaults.default.versionFile, '.quire')
|
|
227
|
-
t.true(defaults.default.projectTemplate.includes('github.com'))
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
231
|
-
// Schema tests
|
|
232
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
233
|
-
|
|
234
|
-
test('schema module defines types for all default keys', async (t) => {
|
|
235
|
-
const schema = await import('./schema.js')
|
|
236
|
-
const defaults = await import('./defaults.js')
|
|
237
|
-
|
|
238
|
-
const schemaKeys = Object.keys(schema.default)
|
|
239
|
-
const defaultKeys = Object.keys(defaults.default)
|
|
240
|
-
|
|
241
|
-
// All default keys should have schema definitions
|
|
242
|
-
for (const key of defaultKeys) {
|
|
243
|
-
t.true(schemaKeys.includes(key), `Schema should define ${key}`)
|
|
244
|
-
}
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
test('schema module defines valid types for all properties', async (t) => {
|
|
248
|
-
const schema = await import('./schema.js')
|
|
249
|
-
|
|
250
|
-
const validTypes = ['string', 'boolean', 'number', 'object', 'array']
|
|
251
|
-
for (const [key, value] of Object.entries(schema.default)) {
|
|
252
|
-
t.true(validTypes.includes(value.type), `${key} should have a valid type`)
|
|
253
|
-
}
|
|
254
|
-
})
|
|
255
|
-
|
|
256
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
257
|
-
// Migrations tests
|
|
258
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
259
|
-
|
|
260
|
-
test('migrations module exports migration functions', async (t) => {
|
|
261
|
-
const migrations = await import('./migrations.js')
|
|
262
|
-
|
|
263
|
-
t.is(typeof migrations.default, 'object')
|
|
264
|
-
t.true('1.0.0' in migrations.default)
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
test('migrations 1.0.0 sets updateChannel', async (t) => {
|
|
268
|
-
const { sandbox } = t.context
|
|
269
|
-
|
|
270
|
-
const migrations = await import('./migrations.js')
|
|
271
|
-
|
|
272
|
-
const mockStore = {
|
|
273
|
-
get: sandbox.stub(),
|
|
274
|
-
set: sandbox.stub()
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
migrations.default['1.0.0'](mockStore)
|
|
278
|
-
|
|
279
|
-
t.true(mockStore.set.calledOnce)
|
|
280
|
-
t.true(mockStore.set.calledWith('updateChannel', 'latest'))
|
|
281
|
-
})
|
package/src/lib/conf/format.js
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Display formatting functions for Quire configuration
|
|
3
|
-
*
|
|
4
|
-
* Provides human-readable formatting with optional chalk styling.
|
|
5
|
-
* Separated from helpers.js to isolate the chalk dependency
|
|
6
|
-
* from the pure validation/coercion functions.
|
|
7
|
-
*
|
|
8
|
-
* @see ./helpers.js for schema-aware validation and coercion
|
|
9
|
-
*/
|
|
10
|
-
import chalk from 'chalk'
|
|
11
|
-
import schema from './schema.js'
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Get the description for a configuration key from the schema
|
|
15
|
-
*
|
|
16
|
-
* @param {string} key - Configuration key
|
|
17
|
-
* @returns {string|undefined} Description string, or undefined if not found
|
|
18
|
-
*/
|
|
19
|
-
export function getKeyDescription(key) {
|
|
20
|
-
return schema[key]?.description
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Format all settings as a human-readable string
|
|
25
|
-
*
|
|
26
|
-
* @param {Object} store - Configuration store (key-value pairs)
|
|
27
|
-
* @param {Object} [options]
|
|
28
|
-
* @param {string} [options.configPath] - Path to the config file (shown in header)
|
|
29
|
-
* @param {boolean} [options.showInternal=false] - Include __internal__ keys
|
|
30
|
-
* @param {boolean} [options.useColor=false] - Apply chalk styling to output
|
|
31
|
-
* @returns {string} Formatted multiline settings display
|
|
32
|
-
*/
|
|
33
|
-
export function formatSettings(store, { configPath, showInternal = false, useColor = false } = {}) {
|
|
34
|
-
const style = useColor
|
|
35
|
-
? { bold: chalk.bold, cyan: chalk.cyan, dim: chalk.dim }
|
|
36
|
-
: { bold: (s) => s, cyan: (s) => s, dim: (s) => s }
|
|
37
|
-
|
|
38
|
-
const lines = []
|
|
39
|
-
|
|
40
|
-
if (configPath) {
|
|
41
|
-
lines.push(style.bold(`quire-cli configuration ${configPath}`))
|
|
42
|
-
} else {
|
|
43
|
-
lines.push(style.bold('quire-cli configuration'))
|
|
44
|
-
}
|
|
45
|
-
lines.push('')
|
|
46
|
-
|
|
47
|
-
for (const [key, value] of Object.entries(store)) {
|
|
48
|
-
if ((key.startsWith('__internal__') || key === 'projects') && !showInternal) continue
|
|
49
|
-
const description = getKeyDescription(key)
|
|
50
|
-
lines.push(` ${style.cyan(key)}: ${JSON.stringify(value)}`)
|
|
51
|
-
if (description) {
|
|
52
|
-
lines.push(` ${style.dim(description)}`)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
lines.push('')
|
|
57
|
-
lines.push(style.dim('Use "quire settings set <key> <value>" to change a setting'))
|
|
58
|
-
|
|
59
|
-
return lines.join('\n')
|
|
60
|
-
}
|