sprag-cli 3.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- package/src/window-labels.js +64 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Child-process shim between the synchronous doc2md pipeline and the async
|
|
4
|
+
* fig converter. Prints the conversion result as one JSON object on stdout,
|
|
5
|
+
* exactly like the Python converter does, so the caller treats both the same.
|
|
6
|
+
*
|
|
7
|
+
* Usage: node fig2md-runner.cjs <file.fig> <userDataDir>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const { convertFig } = require('./fig2md.cjs');
|
|
13
|
+
|
|
14
|
+
const [file, userDataDir] = process.argv.slice(2);
|
|
15
|
+
convertFig(file, userDataDir)
|
|
16
|
+
.then((result) => { process.stdout.write(JSON.stringify(result)); })
|
|
17
|
+
.catch((e) => {
|
|
18
|
+
process.stdout.write(JSON.stringify({
|
|
19
|
+
ok: false, reason: 'convert-failed', detail: String(e && e.message || e).slice(0, 300),
|
|
20
|
+
}));
|
|
21
|
+
});
|
package/src/fig2md.cjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fig2md β Markdown rendering of a Figma `.fig` export.
|
|
3
|
+
*
|
|
4
|
+
* Planning documents increasingly live in Figma rather than PowerPoint, and a
|
|
5
|
+
* `.fig` handed to the model is even more opaque than a pptx: the container
|
|
6
|
+
* is a zip, but the payload inside (`canvas.fig`) is Figma's binary kiwi
|
|
7
|
+
* format, so there is no XML to fall back on. Without a converter the file is
|
|
8
|
+
* simply unreadable.
|
|
9
|
+
*
|
|
10
|
+
* markitdown does not speak this format, so the conversion runs in Node with
|
|
11
|
+
* [openfig-core] (MIT, three small pure-JS dependencies). The parser is not
|
|
12
|
+
* bundled: this package deliberately ships with zero dependencies, so
|
|
13
|
+
* openfig-core is installed on demand into the tool's own state directory β
|
|
14
|
+
* the same arrangement as the markitdown venv, for the same reason.
|
|
15
|
+
*
|
|
16
|
+
* Verified 2026-09-06 against real files: a community Bootstrap UI kit
|
|
17
|
+
* (8.1MB, 4,155 nodes, 1,312 of them text) and a 52MB Tailwind kit, each
|
|
18
|
+
* converting in under a second, plus a round-trip fixture whose Korean text
|
|
19
|
+
* nodes came back byte-identical. Both .fig vintages parse β the current
|
|
20
|
+
* zip container and the older bare fig-kiwi stream.
|
|
21
|
+
*
|
|
22
|
+
* [openfig-core]: https://github.com/OpenFig-org/openfig-core
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const { spawnSync } = require('node:child_process');
|
|
30
|
+
|
|
31
|
+
// `0.4.x` rather than `^0.4.1` on purpose: the Windows install goes through
|
|
32
|
+
// cmd.exe, where `^` is the escape character and would be eaten before npm
|
|
33
|
+
// ever saw it. The range is the same one caret means for a 0.x package.
|
|
34
|
+
const FIG_PARSER_SPEC = 'openfig-core@0.4.x';
|
|
35
|
+
|
|
36
|
+
/** Where the on-demand parser install lives, under the tool's state dir. */
|
|
37
|
+
function managedFigDir(userDataDir) {
|
|
38
|
+
return path.join(userDataDir, 'doc2md-fig');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadParser(userDataDir) {
|
|
42
|
+
try {
|
|
43
|
+
// eslint-disable-next-line import/no-dynamic-require
|
|
44
|
+
return require(path.join(managedFigDir(userDataDir), 'node_modules', 'openfig-core', 'dist', 'index.cjs'));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Install openfig-core into the managed directory. Mirrors the markitdown
|
|
52
|
+
* venv install: network once, probe as the acceptance test.
|
|
53
|
+
*/
|
|
54
|
+
function installFigParser(userDataDir, { onProgress = () => {} } = {}) {
|
|
55
|
+
const dir = managedFigDir(userDataDir);
|
|
56
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
57
|
+
const pkgJson = path.join(dir, 'package.json');
|
|
58
|
+
if (!fs.existsSync(pkgJson)) {
|
|
59
|
+
fs.writeFileSync(pkgJson, JSON.stringify({ name: 'doc2md-fig', private: true }) + '\n');
|
|
60
|
+
}
|
|
61
|
+
onProgress(`installing ${FIG_PARSER_SPEC}`);
|
|
62
|
+
// npm on Windows is npm.cmd, which spawnSync cannot execute directly β it
|
|
63
|
+
// needs the shell. Elsewhere the shell is avoided, since the package spec
|
|
64
|
+
// would then go through shell quoting for no benefit.
|
|
65
|
+
const isWindows = process.platform === 'win32';
|
|
66
|
+
const r = spawnSync(isWindows ? 'npm.cmd' : 'npm', ['install', '--no-audit', '--no-fund', '--silent', FIG_PARSER_SPEC], {
|
|
67
|
+
cwd: dir,
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
timeout: 300_000,
|
|
70
|
+
windowsHide: true,
|
|
71
|
+
shell: isWindows,
|
|
72
|
+
});
|
|
73
|
+
if (r.status !== 0) {
|
|
74
|
+
return { ok: false, reason: 'npm-failed', detail: (r.stderr || '').slice(0, 400) };
|
|
75
|
+
}
|
|
76
|
+
if (!loadParser(userDataDir)) {
|
|
77
|
+
return { ok: false, reason: 'import-failed', detail: 'installed, but openfig-core does not load' };
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, dir };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Node name plus type, indented by depth: the skeleton lines of the outline.
|
|
84
|
+
*/
|
|
85
|
+
function heading(node, depth) {
|
|
86
|
+
const name = String(node.name || '').trim() || '(μ΄λ¦ μμ)';
|
|
87
|
+
return `${'#'.repeat(Math.min(depth + 2, 6))} ${name}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Walk the parsed document and render an outline.
|
|
92
|
+
*
|
|
93
|
+
* The traversal follows `childrenMap` (guid β ordered children), which is how
|
|
94
|
+
* the parser exposes hierarchy. Containers become headings, TEXT nodes become
|
|
95
|
+
* body lines, and everything visual (vectors, rectangles, images) is counted
|
|
96
|
+
* rather than listed: in a planning document the words are the content, and
|
|
97
|
+
* two hundred `Rectangle 173` lines would drown them.
|
|
98
|
+
*/
|
|
99
|
+
function renderMarkdown(doc, sourceName) {
|
|
100
|
+
const guidKey = (g) => `${g.sessionID}:${g.localID}`;
|
|
101
|
+
const lines = [];
|
|
102
|
+
const skipped = Object.create(null);
|
|
103
|
+
let textNodes = 0;
|
|
104
|
+
|
|
105
|
+
const CONTAINERS = new Set(['DOCUMENT', 'CANVAS', 'FRAME', 'GROUP', 'SECTION', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE', 'SLIDE', 'SYMBOL']);
|
|
106
|
+
|
|
107
|
+
function walk(node, depth) {
|
|
108
|
+
if (!node || node.phase === 'REMOVED' || node.visible === false) return;
|
|
109
|
+
const type = node.type || '?';
|
|
110
|
+
if (type === 'TEXT') {
|
|
111
|
+
textNodes += 1;
|
|
112
|
+
const text = String(node.textData?.characters || '').trim();
|
|
113
|
+
const name = String(node.name || '').trim();
|
|
114
|
+
// The layer name usually repeats the text's first line; only show it
|
|
115
|
+
// when it says something the text does not.
|
|
116
|
+
if (name && text && !text.startsWith(name) && !name.startsWith(text.slice(0, 20))) {
|
|
117
|
+
lines.push(`- **${name}**: ${text.replace(/\n/g, ' / ')}`);
|
|
118
|
+
} else if (text) {
|
|
119
|
+
lines.push(`- ${text.replace(/\n/g, ' / ')}`);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (CONTAINERS.has(type)) {
|
|
124
|
+
if (type !== 'DOCUMENT') {
|
|
125
|
+
lines.push('', heading(node, depth), '');
|
|
126
|
+
}
|
|
127
|
+
const children = doc.childrenMap?.get?.(guidKey(node.guid))
|
|
128
|
+
|| doc.childrenMap?.[guidKey(node.guid)]
|
|
129
|
+
|| [];
|
|
130
|
+
for (const child of children) walk(child, depth + (type === 'DOCUMENT' ? 0 : 1));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
skipped[type] = (skipped[type] || 0) + 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const root = (doc.nodes || []).find((n) => n.type === 'DOCUMENT');
|
|
137
|
+
if (root) walk(root, 0);
|
|
138
|
+
|
|
139
|
+
const title = doc.meta?.file_name || sourceName;
|
|
140
|
+
const head = [`# ${title}`];
|
|
141
|
+
const skippedText = Object.entries(skipped)
|
|
142
|
+
.sort((a, b) => b[1] - a[1])
|
|
143
|
+
.map(([t, n]) => `${t} ${n}κ°`)
|
|
144
|
+
.join(', ');
|
|
145
|
+
if (skippedText) {
|
|
146
|
+
head.push('', `(ν
μ€νΈ μΈ μκ° μμλ μλ΅νμ΅λλ€: ${skippedText}. μκ° νμΈμ΄ νμνλ©΄ Figmaμμ μλ³Έμ μ¬μμμ€.)`);
|
|
147
|
+
}
|
|
148
|
+
return { markdown: [...head, ...lines, ''].join('\n'), textNodes };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Convert one `.fig` file. Same result contract as the Python converter:
|
|
153
|
+
* `{ ok, markdown, note, truncated, rows, pages, markup_bytes }` or
|
|
154
|
+
* `{ ok: false, reason, detail }`.
|
|
155
|
+
*
|
|
156
|
+
* `markup_bytes` is 0 on purpose. The office formats price their alternative
|
|
157
|
+
* as "unzip and wade through the XML", but a .fig unzips to another binary β
|
|
158
|
+
* there is no readable fallback, so there is no honest baseline to claim and
|
|
159
|
+
* these conversions count as documents handled rather than money saved.
|
|
160
|
+
*/
|
|
161
|
+
async function convertFig(filePath, userDataDir) {
|
|
162
|
+
const parser = loadParser(userDataDir);
|
|
163
|
+
if (!parser) return { ok: false, reason: 'no-figparser' };
|
|
164
|
+
let doc;
|
|
165
|
+
try {
|
|
166
|
+
const buf = fs.readFileSync(filePath);
|
|
167
|
+
// Two vintages of the same extension: current exports are a zip wrapping
|
|
168
|
+
// canvas.fig, older ones are the bare fig-kiwi stream (magic "fig-kiwi").
|
|
169
|
+
// Verified on a real 8.4MB community kit that only the binary path reads.
|
|
170
|
+
doc = buf[0] === 0x50 && buf[1] === 0x4b
|
|
171
|
+
? await parser.parseFig(buf)
|
|
172
|
+
: await parser.parseFigBinary(buf);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
return { ok: false, reason: 'convert-failed', detail: String(e.message || e).slice(0, 300) };
|
|
175
|
+
}
|
|
176
|
+
const { markdown, textNodes } = renderMarkdown(doc, path.basename(filePath, '.fig'));
|
|
177
|
+
if (!textNodes) {
|
|
178
|
+
// A design file with no words converts to an empty outline, which would
|
|
179
|
+
// read as "the document says nothing" β a worse claim than "unreadable".
|
|
180
|
+
return { ok: false, reason: 'no-text', detail: 'the file has no text nodes' };
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, markdown, note: null, truncated: false, rows: 0, pages: 0, markup_bytes: 0 };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
FIG_PARSER_SPEC,
|
|
187
|
+
managedFigDir,
|
|
188
|
+
installFigParser,
|
|
189
|
+
convertFig,
|
|
190
|
+
renderMarkdown,
|
|
191
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* first-run-note β a one-time pointer to where a feature is explained.
|
|
3
|
+
*
|
|
4
|
+
* A CLI that advertises on every invocation stops being a tool, so this fires
|
|
5
|
+
* ONCE per note key and then never again: the shown-at timestamp is persisted
|
|
6
|
+
* next to the other state files and checked before anything is printed.
|
|
7
|
+
*
|
|
8
|
+
* Opt out entirely with CTS_NO_NOTE=1 (also honoured by anything that pipes
|
|
9
|
+
* our output somewhere it does not belong).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { userDataDir } from './paths.js';
|
|
15
|
+
|
|
16
|
+
export function firstRunStatePath() {
|
|
17
|
+
return join(userDataDir(), 'first-run.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function load() {
|
|
21
|
+
try {
|
|
22
|
+
const s = JSON.parse(readFileSync(firstRunStatePath(), 'utf8'));
|
|
23
|
+
return s && typeof s === 'object' ? s : {};
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function markShown(key, now) {
|
|
30
|
+
const state = load();
|
|
31
|
+
state[key] = now;
|
|
32
|
+
const dir = userDataDir();
|
|
33
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
34
|
+
writeFileSync(firstRunStatePath(), JSON.stringify(state) + '\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True only the first time this key is asked about. Best-effort β a
|
|
38
|
+
* read-only state dir just means the note repeats, never that we crash. */
|
|
39
|
+
export function shouldShowOnce(key, { now = Date.now() } = {}) {
|
|
40
|
+
if (process.env.CTS_NO_NOTE === '1') return false;
|
|
41
|
+
if (load()[key]) return false;
|
|
42
|
+
try {
|
|
43
|
+
markShown(key, now);
|
|
44
|
+
} catch {
|
|
45
|
+
/* state dir unwritable β show it, do not fail the command */
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const NOTES = {
|
|
51
|
+
'route-scan': {
|
|
52
|
+
ko: 'πΊ μ΄ κΈ°λ₯μ μ€λͺ
ν μμ: https://www.youtube.com/@DeepPulseKR (μ΄ μλ΄λ ν λ²λ§ νμλ©λλ€)',
|
|
53
|
+
en: 'πΊ How this works, in 3 minutes: https://www.youtube.com/@DeepPulseEN (shown once)',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Print the one-time note for `key`, or nothing. */
|
|
58
|
+
export function printOnce(key, lang = 'en') {
|
|
59
|
+
const note = NOTES[key];
|
|
60
|
+
if (!note || !shouldShowOnce(key)) return;
|
|
61
|
+
console.log('');
|
|
62
|
+
console.log(lang === 'ko' ? note.ko : note.en);
|
|
63
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "resets in" countdown formatter. The 7-day window can be ~5 days
|
|
3
|
+
* away, so we promote whole-day spans into a `Xd Yh` shape; otherwise it's
|
|
4
|
+
* `Xh Ym` for β₯1h and `Xm` under that.
|
|
5
|
+
*
|
|
6
|
+
* @param {number} resetsAt - Unix-epoch seconds when the window resets.
|
|
7
|
+
* @param {Date} [now=new Date()]
|
|
8
|
+
* @returns {string|null} formatted countdown, or null when the input isn't usable.
|
|
9
|
+
*/
|
|
10
|
+
export function formatResetIn(resetsAt, now = new Date()) {
|
|
11
|
+
if (!Number.isFinite(resetsAt)) return null;
|
|
12
|
+
const remainingSec = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
|
|
13
|
+
if (remainingSec <= 0) return '0m';
|
|
14
|
+
const d = Math.floor(remainingSec / 86400);
|
|
15
|
+
const h = Math.floor((remainingSec % 86400) / 3600);
|
|
16
|
+
const m = Math.floor((remainingSec % 3600) / 60);
|
|
17
|
+
if (d > 0) return `${d}d ${h}h`;
|
|
18
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
19
|
+
return `${m}m`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Wall-clock time at which the window resets, in the user's local timezone.
|
|
24
|
+
* Same-day resets show `HH:MM`; resets that cross midnight prepend the weekday
|
|
25
|
+
* (e.g. `Sat 21:30`) so a glance at the statusline doesn't mislead.
|
|
26
|
+
*
|
|
27
|
+
* @param {number} resetsAt - Unix-epoch seconds.
|
|
28
|
+
* @param {Date} [now=new Date()]
|
|
29
|
+
* @returns {string|null}
|
|
30
|
+
*/
|
|
31
|
+
export function formatResetClock(resetsAt, now = new Date()) {
|
|
32
|
+
if (!Number.isFinite(resetsAt)) return null;
|
|
33
|
+
const reset = new Date(resetsAt * 1000);
|
|
34
|
+
if (Number.isNaN(reset.getTime())) return null;
|
|
35
|
+
const hh = String(reset.getHours()).padStart(2, '0');
|
|
36
|
+
const mm = String(reset.getMinutes()).padStart(2, '0');
|
|
37
|
+
const sameDay =
|
|
38
|
+
reset.getFullYear() === now.getFullYear() &&
|
|
39
|
+
reset.getMonth() === now.getMonth() &&
|
|
40
|
+
reset.getDate() === now.getDate();
|
|
41
|
+
if (sameDay) return `${hh}:${mm}`;
|
|
42
|
+
const dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][reset.getDay()];
|
|
43
|
+
return `${dow} ${hh}:${mm}`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function formatReport({ trend }) {
|
|
2
|
+
const header = 'date,hit_rate,api_calls,cache_read,cache_creation,ephemeral_5m,ephemeral_1h,input,output';
|
|
3
|
+
const rows = trend.map(
|
|
4
|
+
(d) =>
|
|
5
|
+
`${d.date},${d.hitRate.toFixed(4)},${d.apiCalls},${d.cacheRead},${d.cacheCreation},${d.ephemeral5m},${d.ephemeral1h},${d.input},${d.output}`,
|
|
6
|
+
);
|
|
7
|
+
return [header, ...rows].join('\n');
|
|
8
|
+
}
|