clementine-agent 1.18.63 → 1.18.64
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/dist/agent/agent-definitions.js +3 -2
- package/dist/gateway/cron-diagnostic-turn.d.ts +0 -3
- package/dist/gateway/cron-diagnostic-turn.js +0 -6
- package/dist/gateway/recent-context.js +4 -57
- package/dist/gateway/router.js +0 -29
- package/dist/tools/admin-tools.js +11 -0
- package/electron-builder.yml +2 -0
- package/package.json +3 -2
- package/scripts/build-desktop-mac.sh +132 -0
|
@@ -54,10 +54,10 @@ const CRON_FIXER_PROMPT = [
|
|
|
54
54
|
'You are the cron-fix specialist. You diagnose and apply fixes to broken cron jobs.',
|
|
55
55
|
'',
|
|
56
56
|
'Workflow:',
|
|
57
|
-
'1.
|
|
57
|
+
'1. If you already know the job name (parent named it, or notification context names it), call `cron_diagnose` first — it returns the bounded recent-run summary, phase status, and inferred root cause in one shot. If you need a list of currently failing jobs, call `list_broken_jobs` instead.',
|
|
58
58
|
'2. For each job the user/parent asked about, check the proposed fix:',
|
|
59
59
|
' - confidence=high + risk=low + autoApply=true → call `apply_broken_job_fix`.',
|
|
60
|
-
' - Otherwise → describe the diagnosis and ask the parent for explicit approval.',
|
|
60
|
+
' - Otherwise → describe the diagnosis and ask the parent for explicit approval before any manual repair.',
|
|
61
61
|
'3. After applying a fix, the verification system auto-rolls-back if the next 3 runs do not improve. You do NOT need to monitor manually.',
|
|
62
62
|
'',
|
|
63
63
|
'Return: a one-paragraph summary of what you applied (or what is blocking apply), per job.',
|
|
@@ -154,6 +154,7 @@ export function buildAgentMap(opts = {}) {
|
|
|
154
154
|
prompt: CRON_FIXER_PROMPT,
|
|
155
155
|
model: 'sonnet',
|
|
156
156
|
tools: [
|
|
157
|
+
'mcp__clementine-tools__cron_diagnose',
|
|
157
158
|
'mcp__clementine-tools__list_broken_jobs',
|
|
158
159
|
'mcp__clementine-tools__apply_broken_job_fix',
|
|
159
160
|
'mcp__clementine-tools__cron_list',
|
|
@@ -20,7 +20,4 @@ export declare function detectCronDiagnosticRequest(text: string, opts?: {
|
|
|
20
20
|
export declare function buildCronDiagnosticResponseForRequest(request: CronDiagnosticRequest, opts?: {
|
|
21
21
|
baseDir: string;
|
|
22
22
|
}): string | null;
|
|
23
|
-
export declare function buildCronDiagnosticResponse(text: string, opts?: {
|
|
24
|
-
baseDir: string;
|
|
25
|
-
}): string | null;
|
|
26
23
|
//# sourceMappingURL=cron-diagnostic-turn.d.ts.map
|
|
@@ -274,10 +274,4 @@ export function buildCronDiagnosticResponseForRequest(request, opts = { baseDir:
|
|
|
274
274
|
}
|
|
275
275
|
return lines.join('\n');
|
|
276
276
|
}
|
|
277
|
-
export function buildCronDiagnosticResponse(text, opts = { baseDir: process.env.CLEMENTINE_HOME || '' }) {
|
|
278
|
-
const request = detectCronDiagnosticRequest(text, { baseDir: opts.baseDir });
|
|
279
|
-
if (!request || !opts.baseDir)
|
|
280
|
-
return null;
|
|
281
|
-
return buildCronDiagnosticResponseForRequest(request, opts);
|
|
282
|
-
}
|
|
283
277
|
//# sourceMappingURL=cron-diagnostic-turn.js.map
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { listBackgroundTasks } from '../agent/background-tasks.js';
|
|
10
10
|
import { buildNotificationContextPrompt, findRecentNotificationContext, looksLikeNotificationFollowup, } from './notification-context.js';
|
|
11
|
-
import {
|
|
11
|
+
import { detectCronDiagnosticRequest, isInternalSyntheticPrompt, } from './cron-diagnostic-turn.js';
|
|
12
12
|
export { isInternalSyntheticPrompt } from './cron-diagnostic-turn.js';
|
|
13
13
|
const RECENT_TASK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
14
14
|
function normalizeForMatch(text) {
|
|
@@ -22,48 +22,6 @@ function normalizeForMatch(text) {
|
|
|
22
22
|
function compactWhitespace(text) {
|
|
23
23
|
return text.replace(/\s+/g, ' ').trim();
|
|
24
24
|
}
|
|
25
|
-
function wantsFix(text) {
|
|
26
|
-
return /\b(fix|repair|solve|handle|diagnose|debug|what broke|what happened|why did|why is|issue|problem|failure|failed|failing)\b/i.test(text);
|
|
27
|
-
}
|
|
28
|
-
function jobMentionedInText(jobName, text) {
|
|
29
|
-
const normalizedText = normalizeForMatch(text);
|
|
30
|
-
const normalizedJob = normalizeForMatch(jobName);
|
|
31
|
-
return !!normalizedJob && normalizedText.includes(normalizedJob);
|
|
32
|
-
}
|
|
33
|
-
function resolveNotificationJob(event, userText, baseDir) {
|
|
34
|
-
const explicit = detectCronDiagnosticRequest(userText, { baseDir });
|
|
35
|
-
if (explicit?.jobName)
|
|
36
|
-
return explicit.jobName;
|
|
37
|
-
const jobs = event.jobNames ?? [];
|
|
38
|
-
if (jobs.length === 1)
|
|
39
|
-
return jobs[0] ?? null;
|
|
40
|
-
const mentioned = jobs.find((job) => jobMentionedInText(job, userText));
|
|
41
|
-
return mentioned ?? null;
|
|
42
|
-
}
|
|
43
|
-
function summarizeCronNotification(event, userText, opts) {
|
|
44
|
-
const jobs = event.jobNames ?? [];
|
|
45
|
-
if (jobs.length === 0)
|
|
46
|
-
return null;
|
|
47
|
-
const targetJob = resolveNotificationJob(event, userText, opts.baseDir);
|
|
48
|
-
if (targetJob) {
|
|
49
|
-
return buildCronDiagnosticResponseForRequest({ jobName: targetJob, wantsFix: wantsFix(userText) }, { baseDir: opts.baseDir });
|
|
50
|
-
}
|
|
51
|
-
const lines = [
|
|
52
|
-
`I am resolving this to the recent cron failure alert: ${jobs.join(', ')}.`,
|
|
53
|
-
'More than one job was in that alert, so I am not going to guess or start background work.',
|
|
54
|
-
'',
|
|
55
|
-
];
|
|
56
|
-
for (const job of jobs.slice(0, 5)) {
|
|
57
|
-
const diagnostic = buildCronDiagnosticResponseForRequest({ jobName: job, wantsFix: false }, { baseDir: opts.baseDir });
|
|
58
|
-
const preview = diagnostic
|
|
59
|
-
? diagnostic.split('\n').slice(1, 4).join(' ')
|
|
60
|
-
: 'No local diagnostic summary available.';
|
|
61
|
-
lines.push(`- ${job}: ${compactWhitespace(preview).slice(0, 260)}`);
|
|
62
|
-
}
|
|
63
|
-
lines.push('');
|
|
64
|
-
lines.push(`Reply \`fix ${jobs[0]}\` or name the job you want me to repair first.`);
|
|
65
|
-
return lines.join('\n');
|
|
66
|
-
}
|
|
67
25
|
function taskMatchesSession(task, sessionKey) {
|
|
68
26
|
return task.sessionKey === sessionKey;
|
|
69
27
|
}
|
|
@@ -151,22 +109,11 @@ export function resolveRecentOperationalContext(sessionKey, text, opts) {
|
|
|
151
109
|
now: opts.now,
|
|
152
110
|
});
|
|
153
111
|
if (notification) {
|
|
154
|
-
if (notification.type === 'cron_failure') {
|
|
155
|
-
const responseText = summarizeCronNotification(notification, text, opts);
|
|
156
|
-
if (responseText) {
|
|
157
|
-
return {
|
|
158
|
-
source: 'notification',
|
|
159
|
-
reason: 'vague-followup-to-cron-failure-notification',
|
|
160
|
-
responseText,
|
|
161
|
-
suppressDeepMode: true,
|
|
162
|
-
eventId: notification.id,
|
|
163
|
-
jobNames: notification.jobNames,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
112
|
return {
|
|
168
113
|
source: 'notification',
|
|
169
|
-
reason:
|
|
114
|
+
reason: notification.type === 'cron_failure'
|
|
115
|
+
? 'vague-followup-to-cron-failure-notification'
|
|
116
|
+
: 'vague-followup-to-proactive-notification',
|
|
170
117
|
promptText: buildNotificationContextPrompt(notification, text),
|
|
171
118
|
suppressDeepMode: true,
|
|
172
119
|
eventId: notification.id,
|
package/dist/gateway/router.js
CHANGED
|
@@ -20,7 +20,6 @@ import { listBackgroundTasks, loadBackgroundTask, markFailed } from '../agent/ba
|
|
|
20
20
|
import { applyAssistantExperienceUpdate, detectApprovalReply, detectLocalTurn } from '../agent/local-turn.js';
|
|
21
21
|
import { buildApprovalFollowupPrompt, detectActionExpectation } from '../agent/action-enforcer.js';
|
|
22
22
|
import { updateClementineJson } from '../config/clementine-json.js';
|
|
23
|
-
import { buildCronDiagnosticResponse } from './cron-diagnostic-turn.js';
|
|
24
23
|
import { classifyIntent } from '../agent/intent-classifier.js';
|
|
25
24
|
import { decideTurn } from '../agent/turn-policy.js';
|
|
26
25
|
import { recordProactiveNotificationEvent, } from './notification-context.js';
|
|
@@ -1441,34 +1440,6 @@ export class Gateway {
|
|
|
1441
1440
|
text = recentContext.promptText;
|
|
1442
1441
|
}
|
|
1443
1442
|
}
|
|
1444
|
-
// Cron "what broke / fix this job" asks should not spin up a broad SDK
|
|
1445
|
-
// session. They are bounded local diagnostics over run summaries and scalar
|
|
1446
|
-
// config only, and they intentionally do not execute the cron job.
|
|
1447
|
-
if (this.isTrustedPersonalSession(sessionKey) && !isInternalSyntheticPrompt(text)) {
|
|
1448
|
-
const cronDiagnostic = buildCronDiagnosticResponse(text, { baseDir: BASE_DIR });
|
|
1449
|
-
if (cronDiagnostic) {
|
|
1450
|
-
const current = this.sessions.get(sessionKey);
|
|
1451
|
-
if (current?.abortController && !current.abortController.signal.aborted) {
|
|
1452
|
-
current.abortController.abort('replaced-by-cron-diagnostic');
|
|
1453
|
-
logger.info({ sessionKey }, 'Interrupted active chat for local cron diagnostic');
|
|
1454
|
-
}
|
|
1455
|
-
this.assistant.injectContext(sessionKey, originalText, cronDiagnostic);
|
|
1456
|
-
if (onText) {
|
|
1457
|
-
try {
|
|
1458
|
-
await onText(cronDiagnostic);
|
|
1459
|
-
}
|
|
1460
|
-
catch { /* channel streaming is best-effort */ }
|
|
1461
|
-
}
|
|
1462
|
-
logger.info({
|
|
1463
|
-
sessionKey,
|
|
1464
|
-
totalMs: Date.now() - tInnerStart,
|
|
1465
|
-
chatMs: Date.now() - localTurnStarted,
|
|
1466
|
-
localCronDiagnostic: true,
|
|
1467
|
-
responseLen: cronDiagnostic.length,
|
|
1468
|
-
}, 'chat:latency');
|
|
1469
|
-
return cronDiagnostic;
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
1443
|
// Show "queued" status if either lane or session lock is contended,
|
|
1473
1444
|
// so the user doesn't stare at "thinking..." for up to 60s while a
|
|
1474
1445
|
// previous message is still processing.
|
|
@@ -1779,5 +1779,16 @@ export function registerAdminTools(server) {
|
|
|
1779
1779
|
`The fix-verification tracker will roll it back automatically if the next runs don't improve. ` +
|
|
1780
1780
|
`Root cause: ${d.rootCause?.slice(0, 200) ?? ""}.`);
|
|
1781
1781
|
});
|
|
1782
|
+
server.tool('cron_diagnose', 'Return a bounded deterministic diagnosis for one cron job — recent run summary, unleashed phase status, last clean success, current scalar config, and the inferred root cause. Reads only from local run history and cron config; does NOT execute the job. Use this when the user asks what is broken with a specific job, before deciding whether to call apply_broken_job_fix or to propose a manual repair.', {
|
|
1783
|
+
jobName: z.string().describe('The job name as shown in CRON.md or list_broken_jobs output (e.g. "audit-inbox-check" or "ross-the-sdr:reply-detection").'),
|
|
1784
|
+
}, async ({ jobName }) => {
|
|
1785
|
+
const { buildCronDiagnosticResponseForRequest } = await import('../gateway/cron-diagnostic-turn.js');
|
|
1786
|
+
const response = buildCronDiagnosticResponseForRequest({ jobName, wantsFix: true }, { baseDir: BASE_DIR });
|
|
1787
|
+
if (!response) {
|
|
1788
|
+
return textResult(`No diagnostic data for \`${jobName}\` — either the job is not configured in CRON.md or there is no run history yet. ` +
|
|
1789
|
+
`Check the job name with cron_list or list_broken_jobs.`);
|
|
1790
|
+
}
|
|
1791
|
+
return textResult(response);
|
|
1792
|
+
});
|
|
1782
1793
|
}
|
|
1783
1794
|
//# sourceMappingURL=admin-tools.js.map
|
package/electron-builder.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clementine-agent",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.64",
|
|
4
4
|
"description": "Clementine — Personal AI Assistant (TypeScript)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"desktop:debug": "npm run build && CLEMENTINE_DESKTOP_DEBUG=1 electron dist/desktop/main.js",
|
|
19
19
|
"desktop:prepare": "npm run build && npm rebuild better-sqlite3",
|
|
20
20
|
"desktop:pack": "npm run desktop:prepare && electron-builder --mac --dir",
|
|
21
|
-
"desktop:dist": "
|
|
21
|
+
"desktop:dist": "scripts/build-desktop-mac.sh",
|
|
22
|
+
"desktop:dist:unnotarized": "CLEMENTINE_ALLOW_UNNOTARIZED=1 scripts/build-desktop-mac.sh",
|
|
22
23
|
"mcp": "tsx src/tools/mcp-server.ts",
|
|
23
24
|
"cli": "tsx src/cli/index.ts",
|
|
24
25
|
"typecheck": "tsc --noEmit",
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
cd "$ROOT_DIR"
|
|
6
|
+
|
|
7
|
+
load_env_file() {
|
|
8
|
+
local file="$1"
|
|
9
|
+
if [[ -f "$file" ]]; then
|
|
10
|
+
set -a
|
|
11
|
+
# shellcheck disable=SC1090
|
|
12
|
+
source "$file"
|
|
13
|
+
set +a
|
|
14
|
+
fi
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
load_env_file "$ROOT_DIR/.env.signing"
|
|
18
|
+
load_env_file "$ROOT_DIR/.env.release"
|
|
19
|
+
load_env_file "$HOME/.clementine/signing.env"
|
|
20
|
+
|
|
21
|
+
if [[ -n "${APPLE_ID_PASSWORD:-}" && -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]]; then
|
|
22
|
+
export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_ID_PASSWORD"
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
if [[ -n "${CLEMENTINE_NOTARY_PROFILE:-}" && -z "${APPLE_KEYCHAIN_PROFILE:-}" ]]; then
|
|
26
|
+
export APPLE_KEYCHAIN_PROFILE="$CLEMENTINE_NOTARY_PROFILE"
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
has_password_creds=false
|
|
30
|
+
if [[ -n "${APPLE_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" && -n "${APPLE_TEAM_ID:-}" ]]; then
|
|
31
|
+
has_password_creds=true
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
has_api_key_creds=false
|
|
35
|
+
if [[ -n "${APPLE_API_KEY:-}" && -n "${APPLE_API_KEY_ID:-}" && -n "${APPLE_API_ISSUER:-}" ]]; then
|
|
36
|
+
has_api_key_creds=true
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
has_keychain_profile=false
|
|
40
|
+
if [[ -n "${APPLE_KEYCHAIN_PROFILE:-}" ]]; then
|
|
41
|
+
has_keychain_profile=true
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
notarytool_args=()
|
|
45
|
+
if [[ "$has_password_creds" == true ]]; then
|
|
46
|
+
notarytool_args=(--apple-id "$APPLE_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$APPLE_TEAM_ID")
|
|
47
|
+
elif [[ "$has_api_key_creds" == true ]]; then
|
|
48
|
+
notarytool_args=(--key "$APPLE_API_KEY" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER")
|
|
49
|
+
elif [[ "$has_keychain_profile" == true ]]; then
|
|
50
|
+
notarytool_args=(--keychain-profile "$APPLE_KEYCHAIN_PROFILE")
|
|
51
|
+
if [[ -n "${APPLE_KEYCHAIN:-}" ]]; then
|
|
52
|
+
notarytool_args=(--keychain "$APPLE_KEYCHAIN" "${notarytool_args[@]}")
|
|
53
|
+
fi
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
if [[ "$has_password_creds" != true && "$has_api_key_creds" != true && "$has_keychain_profile" != true ]]; then
|
|
57
|
+
cat >&2 <<'EOF'
|
|
58
|
+
No Apple notarization credentials were detected, so the release DMG would be signed but not notarized.
|
|
59
|
+
|
|
60
|
+
Set one of these before running npm run desktop:dist:
|
|
61
|
+
- APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID
|
|
62
|
+
- APPLE_API_KEY, APPLE_API_KEY_ID, APPLE_API_ISSUER
|
|
63
|
+
- APPLE_KEYCHAIN_PROFILE
|
|
64
|
+
|
|
65
|
+
This script also accepts:
|
|
66
|
+
- APPLE_ID_PASSWORD as an alias for APPLE_APP_SPECIFIC_PASSWORD
|
|
67
|
+
- CLEMENTINE_NOTARY_PROFILE as an alias for APPLE_KEYCHAIN_PROFILE
|
|
68
|
+
|
|
69
|
+
One-time local keychain setup:
|
|
70
|
+
xcrun notarytool store-credentials clementine --apple-id <apple-id> --team-id 4AR3Y8XD72 --sync
|
|
71
|
+
CLEMENTINE_NOTARY_PROFILE=clementine npm run desktop:dist
|
|
72
|
+
|
|
73
|
+
For a local signed-but-unnotarized test build:
|
|
74
|
+
npm run desktop:dist:unnotarized
|
|
75
|
+
EOF
|
|
76
|
+
if [[ "${CLEMENTINE_ALLOW_UNNOTARIZED:-}" != "1" ]]; then
|
|
77
|
+
exit 1
|
|
78
|
+
fi
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
npm run desktop:prepare
|
|
82
|
+
"$ROOT_DIR/node_modules/.bin/electron-builder" --mac
|
|
83
|
+
|
|
84
|
+
if [[ ${#notarytool_args[@]} -gt 0 ]]; then
|
|
85
|
+
package_version="$(node -p "require('./package.json').version")"
|
|
86
|
+
shopt -s nullglob
|
|
87
|
+
dmg_files=("$ROOT_DIR"/release/Clementine-"$package_version"-mac-*.dmg)
|
|
88
|
+
shopt -u nullglob
|
|
89
|
+
|
|
90
|
+
for dmg_file in "${dmg_files[@]}"; do
|
|
91
|
+
echo "Notarizing DMG wrapper: $(basename "$dmg_file")"
|
|
92
|
+
xcrun notarytool submit "$dmg_file" --wait "${notarytool_args[@]}"
|
|
93
|
+
xcrun stapler staple "$dmg_file"
|
|
94
|
+
xcrun stapler validate "$dmg_file"
|
|
95
|
+
|
|
96
|
+
case "$(uname -m)" in
|
|
97
|
+
arm64) app_builder="$ROOT_DIR/node_modules/app-builder-bin/mac/app-builder_arm64" ;;
|
|
98
|
+
x86_64) app_builder="$ROOT_DIR/node_modules/app-builder-bin/mac/app-builder_amd64" ;;
|
|
99
|
+
*) app_builder="" ;;
|
|
100
|
+
esac
|
|
101
|
+
if [[ -n "$app_builder" && -x "$app_builder" ]]; then
|
|
102
|
+
"$app_builder" blockmap --input "$dmg_file" --output "$dmg_file.blockmap"
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
if [[ -f "$ROOT_DIR/release/latest-mac.yml" ]]; then
|
|
106
|
+
node --input-type=module - "$dmg_file" "$ROOT_DIR/release/latest-mac.yml" <<'NODE'
|
|
107
|
+
import { createHash } from 'node:crypto';
|
|
108
|
+
import { readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
109
|
+
import { basename } from 'node:path';
|
|
110
|
+
|
|
111
|
+
const [dmgPath, latestPath] = process.argv.slice(2);
|
|
112
|
+
const url = basename(dmgPath);
|
|
113
|
+
const sha512 = createHash('sha512').update(readFileSync(dmgPath)).digest('base64');
|
|
114
|
+
const size = statSync(dmgPath).size;
|
|
115
|
+
const lines = readFileSync(latestPath, 'utf8').split(/\r?\n/);
|
|
116
|
+
|
|
117
|
+
for (let index = 0; index < lines.length; index++) {
|
|
118
|
+
if (lines[index].trim() === `- url: ${url}`) {
|
|
119
|
+
for (let cursor = index + 1; cursor < lines.length; cursor++) {
|
|
120
|
+
if (/^\S/.test(lines[cursor]) || /^\s*-\s+url:/.test(lines[cursor])) break;
|
|
121
|
+
if (lines[cursor].trim().startsWith('sha512:')) lines[cursor] = ` sha512: ${sha512}`;
|
|
122
|
+
if (lines[cursor].trim().startsWith('size:')) lines[cursor] = ` size: ${size}`;
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
writeFileSync(latestPath, `${lines.join('\n').replace(/\n*$/, '')}\n`);
|
|
129
|
+
NODE
|
|
130
|
+
fi
|
|
131
|
+
done
|
|
132
|
+
fi
|