syndes 0.1.0 → 0.3.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/README.md +53 -0
- package/analytics/metrics/rework.mjs +8 -2
- package/analytics/metrics/volume.mjs +19 -6
- package/analytics/team.mjs +245 -0
- package/bin/cli.mjs +413 -1
- package/collect/projects.mjs +18 -2
- package/dashboard/api/team.mjs +248 -0
- package/dashboard/router.mjs +7 -0
- package/dashboard/server.mjs +11 -2
- package/dashboard/web/api.js +8 -0
- package/dashboard/web/app.css +45 -1
- package/dashboard/web/app.js +1 -0
- package/dashboard/web/charts.js +191 -0
- package/dashboard/web/ui.js +3 -1
- package/dashboard/web/views/team.js +496 -0
- package/package.json +8 -4
- package/runtime/config.mjs +17 -0
- package/runtime/identity.mjs +96 -0
- package/runtime/paths.mjs +12 -0
- package/runtime/worker.mjs +35 -1
- package/src/briefing.mjs +14 -1
- package/src/doctor.mjs +30 -0
- package/src/preflight.mjs +174 -0
- package/src/term.mjs +42 -3
- package/sync/index.mjs +336 -0
- package/sync/invite.mjs +70 -0
- package/sync/run-sync.mjs +21 -0
- package/sync/share.mjs +143 -0
- package/sync/transport.mjs +203 -0
package/runtime/worker.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { commitsSince, branchOf, headOf } from '../collect/git.mjs';
|
|
|
27
27
|
import { updateRollups } from '../analytics/rollup.mjs';
|
|
28
28
|
import { runCoach } from '../practices/engine.mjs';
|
|
29
29
|
import { pollTails } from '../collect/tail.mjs';
|
|
30
|
+
import { isEnabled as sharingOn, syncOnce, loadState as teamState, teamConfig } from '../sync/index.mjs';
|
|
30
31
|
|
|
31
32
|
/** Events after which the user has paused, so coaching may run. */
|
|
32
33
|
const BREAKPOINTS = new Set(['Stop', 'SessionEnd', 'SubagentStop']);
|
|
@@ -92,7 +93,9 @@ async function run() {
|
|
|
92
93
|
if (appended) await updateRollups();
|
|
93
94
|
if (sawBreakpoint) await runCoach();
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
const shared = shareWithPool();
|
|
97
|
+
|
|
98
|
+
return { appended, tailed: tailed.length, shared };
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
/** @returns {{drafts: object[], touched: Map, breakpoint: boolean}} */
|
|
@@ -233,6 +236,37 @@ function followOtherAgents() {
|
|
|
233
236
|
}
|
|
234
237
|
}
|
|
235
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Publish to the shared pool, if there is one.
|
|
241
|
+
*
|
|
242
|
+
* Throttled hard and deliberately. Without this the pool only ever updates
|
|
243
|
+
* while somebody has a dashboard open, which makes the feature useless in the
|
|
244
|
+
* direction that matters: you want to see what the OTHER person used, and they
|
|
245
|
+
* are not sitting in front of a dashboard — they are working.
|
|
246
|
+
*
|
|
247
|
+
* But the worker runs at every breakpoint, and pushing to a remote that often
|
|
248
|
+
* would be a commit per turn and a network call in a loop nobody asked for. So
|
|
249
|
+
* it exchanges at most once every `team.syncMinutes`, and a failure is noted
|
|
250
|
+
* and dropped: a pool that cannot be reached must never hold up the ledger,
|
|
251
|
+
* which is the only thing here that is actually the record.
|
|
252
|
+
*/
|
|
253
|
+
function shareWithPool() {
|
|
254
|
+
try {
|
|
255
|
+
if (!sharingOn()) return false;
|
|
256
|
+
|
|
257
|
+
const every = Math.max(1, Number(teamConfig().syncMinutes ?? 10)) * 60_000;
|
|
258
|
+
const last = teamState().at ?? 0;
|
|
259
|
+
if (Date.now() - last < every) return false;
|
|
260
|
+
|
|
261
|
+
const state = syncOnce();
|
|
262
|
+
debug('team sync from worker', state);
|
|
263
|
+
return state.ok;
|
|
264
|
+
} catch (error) {
|
|
265
|
+
debug('team sync failed', error?.message);
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
236
270
|
/** A session that stopped reporting ended without a hook. Say so, do not guess a clean close. */
|
|
237
271
|
function reap() {
|
|
238
272
|
return sessions.reapStale().map(({ id, session }) => draft(KIND.SESSION_END, {
|
package/src/briefing.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
bold, grey, cyan, green, write, pad, stream,
|
|
10
10
|
} from './term.mjs';
|
|
11
11
|
import { loadConfig } from '../runtime/config.mjs';
|
|
12
|
+
import { isEnabled as sharingOn, teamConfig } from '../sync/index.mjs';
|
|
12
13
|
import { existsSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
13
14
|
import { dirname } from 'node:path';
|
|
14
15
|
import { dataDir, displayPath, briefingPending } from '../runtime/paths.mjs';
|
|
@@ -49,7 +50,19 @@ export async function runBriefing(result) {
|
|
|
49
50
|
write(` ${rule}`);
|
|
50
51
|
await stream(` ${grey('SynDes keeps a tamper-evident ledger of how you work with coding')}`);
|
|
51
52
|
await stream(` ${grey('agents, scores it, and tells you the few habits worth changing.')}`);
|
|
52
|
-
|
|
53
|
+
|
|
54
|
+
// "Nothing is sent anywhere" is the strongest claim this tool makes, and the
|
|
55
|
+
// moment sharing is on it is false. Printing it anyway — three lines after the
|
|
56
|
+
// user watched a push succeed — would teach them that the rest of what this
|
|
57
|
+
// briefing says cannot be trusted either.
|
|
58
|
+
if (sharingOn()) {
|
|
59
|
+
const scope = teamConfig().scope ?? 'summary';
|
|
60
|
+
await stream(` ${grey('It runs on this machine. Sharing is')} ${bold('on')}${grey(', so daily counts, tokens')}`);
|
|
61
|
+
await stream(` ${grey(`and working hours go to your pool (${scope}). Prompts, file paths and`)}`);
|
|
62
|
+
await stream(` ${grey('commands never leave.')} ${cyan('syndes team status')}`);
|
|
63
|
+
} else {
|
|
64
|
+
await stream(` ${grey('It runs entirely on this machine. Nothing is sent anywhere.')}`);
|
|
65
|
+
}
|
|
53
66
|
write();
|
|
54
67
|
|
|
55
68
|
await stream(` ${bold('EVERYDAY')}`);
|
package/src/doctor.mjs
CHANGED
|
@@ -24,6 +24,8 @@ import { isHeld } from '../runtime/lock.mjs';
|
|
|
24
24
|
import { describe as describeAuth } from '../dashboard/auth.mjs';
|
|
25
25
|
import { probe } from '../notify/index.mjs';
|
|
26
26
|
import { readJson, loadConfig } from '../runtime/config.mjs';
|
|
27
|
+
import { isEnabled as sharingOn, teamConfig, loadState as teamState, readPool, poolRoot } from '../sync/index.mjs';
|
|
28
|
+
import { identity } from '../runtime/identity.mjs';
|
|
27
29
|
import { configFile } from '../runtime/paths.mjs';
|
|
28
30
|
|
|
29
31
|
function versionOf(file) {
|
|
@@ -137,6 +139,34 @@ export async function diagnose({ deep = false } = {}) {
|
|
|
137
139
|
checks.push(note('system unlock', `${lock.system.method} is available here — syndes lock system`));
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
// ── Sharing ───────────────────────────────────────────────────────────────
|
|
143
|
+
if (!sharingOn()) {
|
|
144
|
+
checks.push(note('sharing', 'off — this machine reports only to itself'));
|
|
145
|
+
} else {
|
|
146
|
+
const state = teamState();
|
|
147
|
+
const peers = readPool();
|
|
148
|
+
const mine = peers.find((peer) => peer.deviceId === identity().deviceId);
|
|
149
|
+
|
|
150
|
+
checks.push(state.ok === false
|
|
151
|
+
? fail('pool', `last exchange failed: ${String(state.error ?? 'unknown').slice(0, 80)}`, 'syndes team sync')
|
|
152
|
+
: pass('pool', `${teamConfig().transport} · ${displayPath(poolRoot())}`));
|
|
153
|
+
|
|
154
|
+
checks.push(mine
|
|
155
|
+
? pass('your shelf', `${identity().name} · ${mine.rollups.size} day(s) published`)
|
|
156
|
+
: fail('your shelf', 'nothing of yours is in the pool yet', 'syndes team sync'));
|
|
157
|
+
|
|
158
|
+
// A peer whose shelf has gone quiet is the failure people misread as "they
|
|
159
|
+
// stopped working". Naming it here is cheaper than them guessing.
|
|
160
|
+
const stale = peers.filter((peer) => !peer.isMe && (!peer.lastSeen || Date.now() - peer.lastSeen > 6 * 3_600_000));
|
|
161
|
+
checks.push(stale.length
|
|
162
|
+
? note('peers', `${peers.length - 1} other machine(s), ${stale.length} not synced in 6h: ${stale.map((p) => p.name).join(', ')}`)
|
|
163
|
+
: pass('peers', `${peers.length - 1} other machine(s), all current`));
|
|
164
|
+
|
|
165
|
+
checks.push(note('sharing scope', teamConfig().scope === 'detailed'
|
|
166
|
+
? 'detailed — counts, tokens, hours, project ids and tool mix. Never prompt text.'
|
|
167
|
+
: 'summary — counts, tokens and hours only. No paths, no project ids, no prompt text.'));
|
|
168
|
+
}
|
|
169
|
+
|
|
140
170
|
const config = readJson(configFile);
|
|
141
171
|
if (config.exists && config.error) {
|
|
142
172
|
checks.push(fail('config', `syndes.json does not parse: ${config.error}`, 'fix or delete it; defaults will be used'));
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can this machine actually reach the pool — and if not, what fixes it.
|
|
3
|
+
*
|
|
4
|
+
* Joining a shared repository fails for a handful of dull reasons that all
|
|
5
|
+
* surface as the same unhelpful git error, and one of them is genuinely
|
|
6
|
+
* confusing: accepting a collaborator invite in a browser grants the ACCOUNT
|
|
7
|
+
* access, while git on the machine still has no idea who you are. GitHub then
|
|
8
|
+
* returns 404 rather than admit a private repository exists, so the honest
|
|
9
|
+
* message "you are not signed in" arrives disguised as "no such repo".
|
|
10
|
+
*
|
|
11
|
+
* So every check here answers three things: what was tested, what happened, and
|
|
12
|
+
* the exact next command. A check that cannot answer returns `null` rather than
|
|
13
|
+
* guessing — same rule the platform probes follow.
|
|
14
|
+
*
|
|
15
|
+
* Nothing is installed without being asked. Software installs itself on a
|
|
16
|
+
* developer's machine only when they say so; the most this does unattended is
|
|
17
|
+
* teach git to use credentials the user already has.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
import { which, isMac, isWindows, platformName } from '../runtime/platform.mjs';
|
|
22
|
+
import { debug } from '../runtime/log.mjs';
|
|
23
|
+
|
|
24
|
+
const TIMEOUT_MS = 20_000;
|
|
25
|
+
|
|
26
|
+
function run(binary, args, { timeout = TIMEOUT_MS, quiet = true } = {}) {
|
|
27
|
+
try {
|
|
28
|
+
const out = execFileSync(binary, args, {
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
timeout,
|
|
31
|
+
stdio: quiet ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
32
|
+
windowsHide: true,
|
|
33
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo' },
|
|
34
|
+
});
|
|
35
|
+
return { ok: true, out: (out ?? '').trim(), error: null };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
const stderr = String(error.stderr ?? '').trim();
|
|
38
|
+
return { ok: false, out: '', error: (stderr || error.message).split('\n').slice(-2).join(' ').slice(0, 240) };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How this platform installs the GitHub CLI. */
|
|
43
|
+
export function ghInstallHint() {
|
|
44
|
+
if (isMac) return which('brew') ? 'brew install gh' : 'see https://cli.github.com (or: brew install gh)';
|
|
45
|
+
if (isWindows) return 'winget install --id GitHub.cli';
|
|
46
|
+
return 'see https://cli.github.com — most distros package it as `gh`';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function gitInstallHint() {
|
|
50
|
+
if (isMac) return 'xcode-select --install';
|
|
51
|
+
if (isWindows) return 'winget install --id Git.Git';
|
|
52
|
+
return 'your package manager, e.g. apt install git';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @returns {{installed: boolean, authed: boolean, account: string|null, gitConfigured: boolean}} */
|
|
56
|
+
export function ghState() {
|
|
57
|
+
const gh = which('gh');
|
|
58
|
+
if (!gh) return { installed: false, authed: false, account: null, gitConfigured: false };
|
|
59
|
+
|
|
60
|
+
const status = run(gh, ['auth', 'status']);
|
|
61
|
+
const account = /Logged in to \S+ account (\S+)/.exec(`${status.out}\n${status.error ?? ''}`)?.[1] ?? null;
|
|
62
|
+
|
|
63
|
+
// `gh auth setup-git` writes a credential helper entry; without it a gh login
|
|
64
|
+
// authenticates the gh command and nothing else, which is the trap.
|
|
65
|
+
const helper = run(which('git') ?? 'git', ['config', '--get-regexp', 'credential.*helper']);
|
|
66
|
+
return {
|
|
67
|
+
installed: true,
|
|
68
|
+
authed: Boolean(account),
|
|
69
|
+
account,
|
|
70
|
+
gitConfigured: /gh auth git-credential|gh\b/.test(helper.out),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Everything that must be true before a join can work.
|
|
76
|
+
*
|
|
77
|
+
* @param {string|null} repo the remote to test, or null to skip the reach check
|
|
78
|
+
* @returns {{ok: boolean, checks: object[], blocking: object|null}}
|
|
79
|
+
*/
|
|
80
|
+
export function diagnose(repo = null) {
|
|
81
|
+
const checks = [];
|
|
82
|
+
const git = which('git');
|
|
83
|
+
|
|
84
|
+
checks.push(git
|
|
85
|
+
? { name: 'git', ok: true, detail: git }
|
|
86
|
+
: { name: 'git', ok: false, detail: `not installed on this ${platformName}`, fix: gitInstallHint() });
|
|
87
|
+
|
|
88
|
+
const gh = ghState();
|
|
89
|
+
checks.push(gh.installed
|
|
90
|
+
? { name: 'gh', ok: true, detail: gh.authed ? `signed in as ${gh.account}` : 'installed, not signed in' }
|
|
91
|
+
: { name: 'gh', ok: null, detail: 'not installed — optional, but it is the easiest way to authenticate', fix: ghInstallHint() });
|
|
92
|
+
|
|
93
|
+
if (git && repo && !repo.startsWith('/') && !repo.startsWith('~')) {
|
|
94
|
+
const reach = run(git, ['ls-remote', '--heads', repo]);
|
|
95
|
+
if (reach.ok) {
|
|
96
|
+
checks.push({ name: 'repository', ok: true, detail: 'reachable, and git can authenticate' });
|
|
97
|
+
} else {
|
|
98
|
+
checks.push({
|
|
99
|
+
name: 'repository',
|
|
100
|
+
ok: false,
|
|
101
|
+
detail: explain(reach.error, gh),
|
|
102
|
+
fix: remedy(gh, repo),
|
|
103
|
+
raw: reach.error,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const blocking = checks.find((check) => check.ok === false) ?? null;
|
|
109
|
+
return { ok: !blocking, checks, blocking, gh };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Turn git's message into the actual problem.
|
|
114
|
+
*
|
|
115
|
+
* "Repository not found" on a private repo almost never means the repo is
|
|
116
|
+
* missing — it means GitHub will not confirm it exists to someone it cannot
|
|
117
|
+
* identify. Repeating git's wording would send the user to check the URL, which
|
|
118
|
+
* is the one thing that is usually right.
|
|
119
|
+
*/
|
|
120
|
+
function explain(error, gh) {
|
|
121
|
+
const text = String(error ?? '');
|
|
122
|
+
if (/could not read Username|Authentication failed|terminal prompts disabled/i.test(text)) {
|
|
123
|
+
return gh.authed
|
|
124
|
+
? `git has no credentials for this remote, though gh is signed in as ${gh.account}`
|
|
125
|
+
: 'git has no credentials for this remote';
|
|
126
|
+
}
|
|
127
|
+
if (/not found|does not exist/i.test(text)) {
|
|
128
|
+
return gh.authed
|
|
129
|
+
? `not visible to ${gh.account} — either the invite is not accepted, or git is not using that account`
|
|
130
|
+
: 'private repositories are invisible until git can prove who you are';
|
|
131
|
+
}
|
|
132
|
+
if (/Permission denied \(publickey\)/i.test(text)) return 'no SSH key on this machine that GitHub recognises';
|
|
133
|
+
if (/Could not resolve host|network/i.test(text)) return 'cannot reach the network';
|
|
134
|
+
return text.slice(0, 160);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function remedy(gh, repo) {
|
|
138
|
+
const ssh = repo.startsWith('git@') || repo.startsWith('ssh://');
|
|
139
|
+
if (ssh) return 'add an SSH key to your GitHub account, or use the https:// url instead';
|
|
140
|
+
if (!gh.installed) return `${ghInstallHint()}, then: gh auth login && gh auth setup-git`;
|
|
141
|
+
if (!gh.authed) return 'gh auth login';
|
|
142
|
+
if (!gh.gitConfigured) return 'gh auth setup-git';
|
|
143
|
+
return 'check the collaborator invite has been accepted for this account';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Fix what can be fixed without installing anything.
|
|
148
|
+
*
|
|
149
|
+
* `gh auth login` is interactive by design — it opens a browser and asks the
|
|
150
|
+
* person to confirm — so it is only attempted when there is a terminal to run
|
|
151
|
+
* it in, and it inherits stdio so the user sees and answers it themselves.
|
|
152
|
+
*
|
|
153
|
+
* @returns {{ran: string[], ok: boolean}}
|
|
154
|
+
*/
|
|
155
|
+
export function repair({ interactive = Boolean(process.stdin.isTTY) } = {}) {
|
|
156
|
+
const gh = ghState();
|
|
157
|
+
const ran = [];
|
|
158
|
+
if (!gh.installed) return { ran, ok: false };
|
|
159
|
+
|
|
160
|
+
const binary = which('gh');
|
|
161
|
+
if (!gh.authed) {
|
|
162
|
+
if (!interactive) return { ran, ok: false };
|
|
163
|
+
ran.push('gh auth login');
|
|
164
|
+
const result = run(binary, ['auth', 'login'], { quiet: false, timeout: 300_000 });
|
|
165
|
+
if (!result.ok) return { ran, ok: false };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!ghState().gitConfigured) {
|
|
169
|
+
ran.push('gh auth setup-git');
|
|
170
|
+
const result = run(binary, ['auth', 'setup-git']);
|
|
171
|
+
if (!result.ok) { debug('gh auth setup-git failed', result.error); return { ran, ok: false }; }
|
|
172
|
+
}
|
|
173
|
+
return { ran, ok: ghState().authed };
|
|
174
|
+
}
|
package/src/term.mjs
CHANGED
|
@@ -207,10 +207,50 @@ export function beat(ms = 90) {
|
|
|
207
207
|
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Ask, and always come back with something.
|
|
212
|
+
*
|
|
213
|
+
* Two ways a question never gets answered, both of which used to hang forever:
|
|
214
|
+
*
|
|
215
|
+
* • There is no terminal. Every caller of this function is interactive by
|
|
216
|
+
* nature, so with no tty there is nobody to ask — and reading whatever
|
|
217
|
+
* happens to be on a pipe as though a person typed it is worse than not
|
|
218
|
+
* asking, particularly for the PIN prompt. This follows the rule the rest
|
|
219
|
+
* of the codebase already keeps: a probe that cannot answer returns nothing
|
|
220
|
+
* and the caller degrades, rather than "unsupported" and "broken" looking
|
|
221
|
+
* alike. It is also the only DETERMINISTIC answer available. When stdin is
|
|
222
|
+
* /dev/null — which is exactly what npm hands a postinstall — Node emits no
|
|
223
|
+
* `end` and no `close` on it at all, so nothing short of a timeout would
|
|
224
|
+
* ever notice, and a timeout in a prompt is a guess about how fast someone
|
|
225
|
+
* types.
|
|
226
|
+
*
|
|
227
|
+
* • The user pressed ctrl-D at a real prompt. readline's callback never
|
|
228
|
+
* fires, so `close` is what settles it.
|
|
229
|
+
*
|
|
230
|
+
* Left unhandled, either one leaves the promise pending and Node exits mid
|
|
231
|
+
* install with "unsettled top-level await", which reads as a crash with no cause.
|
|
232
|
+
* An unanswered question resolves empty, and every caller already treats empty
|
|
233
|
+
* as "no answer given".
|
|
234
|
+
*/
|
|
210
235
|
export function ask(question, { silent = false } = {}) {
|
|
211
236
|
return new Promise((resolve) => {
|
|
237
|
+
if (!process.stdin.isTTY || process.stdin.readableEnded || process.stdin.destroyed) {
|
|
238
|
+
resolve('');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
212
242
|
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
213
243
|
|
|
244
|
+
let settled = false;
|
|
245
|
+
const done = (answer) => {
|
|
246
|
+
if (settled) return;
|
|
247
|
+
settled = true;
|
|
248
|
+
try { rl.close(); } catch { /* already closing */ }
|
|
249
|
+
resolve(answer);
|
|
250
|
+
};
|
|
251
|
+
rl.on('close', () => done(''));
|
|
252
|
+
rl.on('error', () => done(''));
|
|
253
|
+
|
|
214
254
|
if (silent) {
|
|
215
255
|
// Echo off by hand: readline has no password mode, and a password printed
|
|
216
256
|
// into a terminal that may be scrolled back or recorded is unacceptable.
|
|
@@ -220,13 +260,12 @@ export function ask(question, { silent = false } = {}) {
|
|
|
220
260
|
rl.question('', (answer) => {
|
|
221
261
|
rl.input.off('data', redraw);
|
|
222
262
|
rl.output.write('\n');
|
|
223
|
-
|
|
224
|
-
resolve(answer);
|
|
263
|
+
done(answer);
|
|
225
264
|
});
|
|
226
265
|
return;
|
|
227
266
|
}
|
|
228
267
|
|
|
229
|
-
rl.question(question, (answer) =>
|
|
268
|
+
rl.question(question, (answer) => done(answer.trim()));
|
|
230
269
|
});
|
|
231
270
|
}
|
|
232
271
|
|