pinglet-cli 0.1.6 → 0.2.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 +1 -1
- package/dist/adapters/claude.js +139 -96
- package/dist/api.js +9 -6
- package/dist/cli.js +2 -2
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/install.js +2 -2
- package/dist/commands/login.js +1 -1
- package/dist/commands/post.js +5 -4
- package/dist/render.js +16 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ codex # turn 완료 시 macOS 알림으로 Ping 전달 (experimental)
|
|
|
41
41
|
| `pinglet install [--api <url>] [--force]` | Claude Code / Codex 자동 감지 후 integration 설치 + 서버 등록 + 초기 feed 캐시 |
|
|
42
42
|
| `pinglet uninstall [--purge]` | integration 제거, 기존 설정 복원. `--purge`는 `~/.pinglet`까지 삭제 |
|
|
43
43
|
| `pinglet login [--token <jwt>]` | GitHub 로그인 후 익명 설치를 계정에 연결 |
|
|
44
|
-
| `pinglet post "메시지" [--category <c>]` | 메시지 작성 (
|
|
44
|
+
| `pinglet post "메시지" [--category <c>]` | 메시지 작성 (GitHub 로그인 필요, 읽기는 로그인 없이 가능) |
|
|
45
45
|
| `pinglet doctor` | 설치/캐시/큐/서버 상태 진단 |
|
|
46
46
|
| `pinglet ping` | 메시지 미리보기 (이벤트 기록 없음) |
|
|
47
47
|
|
package/dist/adapters/claude.js
CHANGED
|
@@ -131,19 +131,42 @@ const MAX_VERBS = 30;
|
|
|
131
131
|
/** spinner 한 줄에 "(27s · ↓ 1.0k tokens)" 등이 뒤에 붙으므로 여유를 둔다. */
|
|
132
132
|
const MAX_VERB_LEN = 60;
|
|
133
133
|
function readSettings() {
|
|
134
|
+
return readSettingsWithMtime().settings;
|
|
135
|
+
}
|
|
136
|
+
function settingsMtimeMs() {
|
|
137
|
+
try {
|
|
138
|
+
return fs.statSync(SETTINGS_PATH).mtimeMs;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null; // 파일 없음
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function readSettingsWithMtime() {
|
|
145
|
+
const mtimeMs = settingsMtimeMs();
|
|
134
146
|
try {
|
|
135
|
-
return
|
|
147
|
+
return {
|
|
148
|
+
settings: JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8")),
|
|
149
|
+
mtimeMs,
|
|
150
|
+
};
|
|
136
151
|
}
|
|
137
152
|
catch {
|
|
138
|
-
return {};
|
|
153
|
+
return { settings: {}, mtimeMs };
|
|
139
154
|
}
|
|
140
155
|
}
|
|
141
|
-
|
|
156
|
+
/**
|
|
157
|
+
* mtime이 읽던 시점 그대로일 때만 쓴다 — 변경이 감지되면 false를 돌려주고
|
|
158
|
+
* 호출부가 다시 읽어 재적용한다. Claude Code 자신도 이 파일을 쓰기 때문에,
|
|
159
|
+
* 그 사이의 변경을 우리 스냅샷으로 덮어써 사용자 설정을 유실하면 안 된다.
|
|
160
|
+
*/
|
|
161
|
+
function writeSettings(settings, expectedMtimeMs) {
|
|
142
162
|
fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
163
|
+
if (settingsMtimeMs() !== expectedMtimeMs)
|
|
164
|
+
return false;
|
|
143
165
|
// settings.json은 Claude Code가 수시로 읽는 파일이라 tmp+rename으로 원자적으로 쓴다.
|
|
144
166
|
const tmp = SETTINGS_PATH + ".pinglet-tmp";
|
|
145
167
|
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
|
146
168
|
fs.renameSync(tmp, SETTINGS_PATH);
|
|
169
|
+
return true;
|
|
147
170
|
}
|
|
148
171
|
function isPingletTip(tip) {
|
|
149
172
|
return tip.startsWith(PINGLET_TIP_MARKER);
|
|
@@ -194,47 +217,58 @@ function armSpinnerMessage(config, message) {
|
|
|
194
217
|
const adapter = config.adapters.claude;
|
|
195
218
|
if (!adapter)
|
|
196
219
|
return false;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const pingletVerbs = message ? buildPingletVerbs([message]) : [];
|
|
209
|
-
// armed할 메시지가 없으면 spinner를 기본 상태로 되돌린다.
|
|
210
|
-
if (pingletVerbs.length === 0) {
|
|
211
|
-
if (!isOurs)
|
|
212
|
-
return false;
|
|
213
|
-
const backup = adapter.spinnerVerbsBackup;
|
|
214
|
-
if (backup) {
|
|
215
|
-
settings.spinnerVerbs = backup;
|
|
216
|
-
delete adapter.spinnerVerbsBackup;
|
|
220
|
+
// 읽기~쓰기 사이에 다른 프로세스(Claude Code 등)가 파일을 바꿨으면
|
|
221
|
+
// writeSettings가 거부하므로, 다시 읽어 재적용한다.
|
|
222
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
223
|
+
const { settings, mtimeMs } = readSettingsWithMtime();
|
|
224
|
+
cleanupSpinnerTips(settings, config);
|
|
225
|
+
const existing = settings.spinnerVerbs;
|
|
226
|
+
const existingVerbs = existing?.verbs ?? [];
|
|
227
|
+
// 사용자가 원래 갖고 있던 spinnerVerbs는 최초 1회 백업해 두고 uninstall 시 복원한다.
|
|
228
|
+
const isOurs = existingVerbs.some(isPingletTip);
|
|
229
|
+
if (existing && !isOurs && adapter.spinnerVerbsBackup === undefined) {
|
|
230
|
+
adapter.spinnerVerbsBackup = existing;
|
|
217
231
|
(0, config_1.saveConfig)(config);
|
|
218
232
|
}
|
|
219
|
-
|
|
220
|
-
|
|
233
|
+
const userVerbs = existingVerbs.filter((verb) => !isPingletTip(verb));
|
|
234
|
+
const pingletVerbs = message ? buildPingletVerbs([message]) : [];
|
|
235
|
+
// armed할 메시지가 없으면 spinner를 기본 상태로 되돌린다.
|
|
236
|
+
if (pingletVerbs.length === 0) {
|
|
237
|
+
if (!isOurs)
|
|
238
|
+
return false;
|
|
239
|
+
const backup = adapter.spinnerVerbsBackup;
|
|
240
|
+
if (backup) {
|
|
241
|
+
settings.spinnerVerbs = backup;
|
|
242
|
+
}
|
|
243
|
+
else if (userVerbs.length > 0) {
|
|
244
|
+
settings.spinnerVerbs = { ...existing, verbs: userVerbs };
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
delete settings.spinnerVerbs;
|
|
248
|
+
}
|
|
249
|
+
if (writeSettings(settings, mtimeMs)) {
|
|
250
|
+
// 쓰기가 확정된 뒤에만 백업을 소비한다 (실패 시 백업 유실 방지).
|
|
251
|
+
if (backup) {
|
|
252
|
+
delete adapter.spinnerVerbsBackup;
|
|
253
|
+
(0, config_1.saveConfig)(config);
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
continue;
|
|
221
258
|
}
|
|
222
|
-
|
|
223
|
-
|
|
259
|
+
const next = [...userVerbs, ...pingletVerbs];
|
|
260
|
+
// 같은 메시지를 다시 armed하는 경우 파일 쓰기를 생략한다 (300ms 렌더링 경로 보호).
|
|
261
|
+
if (existing?.mode === "replace" &&
|
|
262
|
+
existingVerbs.length === next.length &&
|
|
263
|
+
existingVerbs.every((v, i) => v === next[i])) {
|
|
264
|
+
return true;
|
|
224
265
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const next = [...userVerbs, ...pingletVerbs];
|
|
229
|
-
// 같은 메시지를 다시 armed하는 경우 파일 쓰기를 생략한다 (300ms 렌더링 경로 보호).
|
|
230
|
-
if (existing?.mode === "replace" &&
|
|
231
|
-
existingVerbs.length === next.length &&
|
|
232
|
-
existingVerbs.every((v, i) => v === next[i])) {
|
|
233
|
-
return true;
|
|
266
|
+
settings.spinnerVerbs = { mode: "replace", verbs: next };
|
|
267
|
+
if (writeSettings(settings, mtimeMs))
|
|
268
|
+
return true;
|
|
234
269
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return true;
|
|
270
|
+
// 계속 충돌하면 이번 tick은 건너뛴다 — 다음 statusline tick에서 재시도된다.
|
|
271
|
+
return false;
|
|
238
272
|
}
|
|
239
273
|
/** doctor용: spinner verb로 등록된 Pinglet 메시지 개수. */
|
|
240
274
|
function spinnerVerbsStatus() {
|
|
@@ -247,75 +281,84 @@ function isClaudeIntegrationInstalled() {
|
|
|
247
281
|
return settings.statusLine?.command?.includes("pinglet") ?? false;
|
|
248
282
|
}
|
|
249
283
|
function installClaudeIntegration(config, options = {}) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
284
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
285
|
+
const { settings, mtimeMs } = readSettingsWithMtime();
|
|
286
|
+
const existing = settings.statusLine;
|
|
287
|
+
const isOurs = existing?.command?.includes("pinglet");
|
|
288
|
+
if (existing && !isOurs && !options.force) {
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
reason: "기존 statusLine 설정이 있습니다. 덮어쓰려면 --force로 다시 실행하세요.",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
config.adapters.claude = {
|
|
295
|
+
// 재설치 시 기존 백업(spinnerVerbsBackup 등)이 날아가지 않게 유지한다.
|
|
296
|
+
...config.adapters.claude,
|
|
297
|
+
installedAt: new Date().toISOString(),
|
|
298
|
+
settingsPath: SETTINGS_PATH,
|
|
299
|
+
// 사용자의 기존 설정은 백업해 두고 uninstall 시 복원한다.
|
|
300
|
+
...(existing && !isOurs && { statusLineBackup: existing }),
|
|
257
301
|
};
|
|
302
|
+
(0, config_1.saveConfig)(config);
|
|
303
|
+
settings.statusLine = { type: "command", command: statusLineCommand() };
|
|
304
|
+
uninstallPromptHook(settings); // 구버전 hook 방식 흔적 정리
|
|
305
|
+
if (!writeSettings(settings, mtimeMs))
|
|
306
|
+
continue; // 다른 프로세스와 충돌 → 재읽기
|
|
307
|
+
installSlashCommand();
|
|
308
|
+
// spinner verb는 로컬 캐시에 실제 feed가 있을 때만 채운다. 없으면 기본 spinner 유지.
|
|
309
|
+
armSpinnerMessage(config, (0, cache_1.loadFeedMessages)()[0] ?? null);
|
|
310
|
+
return { ok: true };
|
|
258
311
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
installedAt: new Date().toISOString(),
|
|
263
|
-
settingsPath: SETTINGS_PATH,
|
|
264
|
-
// 사용자의 기존 설정은 백업해 두고 uninstall 시 복원한다.
|
|
265
|
-
...(existing && !isOurs && { statusLineBackup: existing }),
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
reason: "settings.json이 계속 변경되고 있어 설치하지 못했습니다. 잠시 후 다시 시도하세요.",
|
|
266
315
|
};
|
|
267
|
-
(0, config_1.saveConfig)(config);
|
|
268
|
-
settings.statusLine = { type: "command", command: statusLineCommand() };
|
|
269
|
-
uninstallPromptHook(settings); // 구버전 hook 방식 흔적 정리
|
|
270
|
-
writeSettings(settings);
|
|
271
|
-
installSlashCommand();
|
|
272
|
-
// spinner verb는 로컬 캐시에 실제 feed가 있을 때만 채운다. 없으면 기본 spinner 유지.
|
|
273
|
-
armSpinnerMessage(config, (0, cache_1.loadFeedMessages)()[0] ?? null);
|
|
274
|
-
return { ok: true };
|
|
275
316
|
}
|
|
276
317
|
function uninstallClaudeIntegration(config) {
|
|
277
|
-
|
|
278
|
-
let
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (
|
|
282
|
-
|
|
318
|
+
let settingsChanged = false;
|
|
319
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
320
|
+
const { settings, mtimeMs } = readSettingsWithMtime();
|
|
321
|
+
settingsChanged = false;
|
|
322
|
+
if (settings.statusLine?.command?.includes("pinglet")) {
|
|
323
|
+
const backup = config.adapters.claude?.statusLineBackup;
|
|
324
|
+
if (backup) {
|
|
325
|
+
settings.statusLine = backup;
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
delete settings.statusLine;
|
|
329
|
+
}
|
|
330
|
+
settingsChanged = true;
|
|
283
331
|
}
|
|
284
|
-
|
|
285
|
-
|
|
332
|
+
if (cleanupSpinnerTips(settings, config)) {
|
|
333
|
+
settingsChanged = true;
|
|
286
334
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (cleanupSpinnerTips(settings, config)) {
|
|
290
|
-
changed = true;
|
|
291
|
-
}
|
|
292
|
-
if (uninstallPromptHook(settings)) {
|
|
293
|
-
changed = true;
|
|
294
|
-
}
|
|
295
|
-
if (uninstallSlashCommand()) {
|
|
296
|
-
changed = true;
|
|
297
|
-
}
|
|
298
|
-
const verbs = settings.spinnerVerbs;
|
|
299
|
-
if (verbs?.verbs?.some(isPingletTip)) {
|
|
300
|
-
const verbsBackup = config.adapters.claude?.spinnerVerbsBackup;
|
|
301
|
-
const userVerbs = verbs.verbs.filter((verb) => !isPingletTip(verb));
|
|
302
|
-
if (verbsBackup) {
|
|
303
|
-
settings.spinnerVerbs = verbsBackup;
|
|
304
|
-
}
|
|
305
|
-
else if (userVerbs.length > 0) {
|
|
306
|
-
settings.spinnerVerbs = { ...verbs, verbs: userVerbs };
|
|
335
|
+
if (uninstallPromptHook(settings)) {
|
|
336
|
+
settingsChanged = true;
|
|
307
337
|
}
|
|
308
|
-
|
|
309
|
-
|
|
338
|
+
const verbs = settings.spinnerVerbs;
|
|
339
|
+
if (verbs?.verbs?.some(isPingletTip)) {
|
|
340
|
+
const verbsBackup = config.adapters.claude?.spinnerVerbsBackup;
|
|
341
|
+
const userVerbs = verbs.verbs.filter((verb) => !isPingletTip(verb));
|
|
342
|
+
if (verbsBackup) {
|
|
343
|
+
settings.spinnerVerbs = verbsBackup;
|
|
344
|
+
}
|
|
345
|
+
else if (userVerbs.length > 0) {
|
|
346
|
+
settings.spinnerVerbs = { ...verbs, verbs: userVerbs };
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
delete settings.spinnerVerbs;
|
|
350
|
+
}
|
|
351
|
+
settingsChanged = true;
|
|
310
352
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
353
|
+
if (!settingsChanged)
|
|
354
|
+
break;
|
|
355
|
+
if (writeSettings(settings, mtimeMs))
|
|
356
|
+
break; // 충돌 시 재읽기 후 재적용
|
|
315
357
|
}
|
|
358
|
+
const commandRemoved = uninstallSlashCommand();
|
|
316
359
|
if (config.adapters.claude) {
|
|
317
360
|
delete config.adapters.claude;
|
|
318
361
|
(0, config_1.saveConfig)(config);
|
|
319
362
|
}
|
|
320
|
-
return
|
|
363
|
+
return settingsChanged || commandRemoved;
|
|
321
364
|
}
|
package/dist/api.js
CHANGED
|
@@ -140,12 +140,12 @@ async function fetchMessageStats(config, messageId) {
|
|
|
140
140
|
}
|
|
141
141
|
/**
|
|
142
142
|
* POST /messages — 메시지 작성 (분당 5개 제한).
|
|
143
|
-
*
|
|
143
|
+
* 작성은 GitHub 로그인이 필요하다 (읽기는 계속 익명).
|
|
144
144
|
*/
|
|
145
145
|
async function createMessage(config, text, category) {
|
|
146
|
-
const token = config.userToken
|
|
146
|
+
const token = config.userToken;
|
|
147
147
|
if (!token)
|
|
148
|
-
return { ok: false, error: "
|
|
148
|
+
return { ok: false, error: "LOGIN_REQUIRED" };
|
|
149
149
|
try {
|
|
150
150
|
const res = await fetch(config.apiBaseUrl + "/messages", {
|
|
151
151
|
method: "POST",
|
|
@@ -174,10 +174,13 @@ async function createMessage(config, text, category) {
|
|
|
174
174
|
return { ok: false, error: "NETWORK" };
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
|
-
/**
|
|
178
|
-
|
|
177
|
+
/**
|
|
178
|
+
* POST /installations/link — 로그인 후 기존 익명 설치를 계정에 연결한다.
|
|
179
|
+
* 소유 증명으로 installation "토큰"을 보낸다 (ID만으로는 연결 불가).
|
|
180
|
+
*/
|
|
181
|
+
async function linkInstallation(config, installationToken) {
|
|
179
182
|
if (!config.userToken)
|
|
180
183
|
return false;
|
|
181
|
-
const res = await request(config, "POST", "/installations/link", { token: config.userToken, body: {
|
|
184
|
+
const res = await request(config, "POST", "/installations/link", { token: config.userToken, body: { installationToken } });
|
|
182
185
|
return res?.linked === true;
|
|
183
186
|
}
|
package/dist/cli.js
CHANGED
|
@@ -19,8 +19,8 @@ const HELP = `pinglet v${config_1.VERSION} — AI가 생각하는 동안 만나
|
|
|
19
19
|
install [--api <url>] [--force] Claude Code / Codex 자동 감지 후 integration 설치
|
|
20
20
|
[--claude | --codex] 특정 agent만 설치
|
|
21
21
|
uninstall [--purge] integration 제거 (--purge: ~/.pinglet까지 삭제)
|
|
22
|
-
login [--token <jwt>] GitHub 계정 연결
|
|
23
|
-
post "메시지" [--category <c>] 메시지 작성 (
|
|
22
|
+
login [--token <jwt>] GitHub 계정 연결
|
|
23
|
+
post "메시지" [--category <c>] 메시지 작성 (GitHub 로그인 필요, 읽기는 익명 가능)
|
|
24
24
|
doctor 설치/캐시/서버 상태 진단
|
|
25
25
|
ping 메시지 미리보기
|
|
26
26
|
|
package/dist/commands/doctor.js
CHANGED
package/dist/commands/install.js
CHANGED
|
@@ -73,6 +73,6 @@ async function runInstall(args) {
|
|
|
73
73
|
}
|
|
74
74
|
console.log("\n설치 완료! 평소처럼 claude 또는 codex를 실행하면 Ping이 표시됩니다.");
|
|
75
75
|
console.log(" 상태 확인: pinglet doctor");
|
|
76
|
-
console.log(' 메시지 작성: pinglet post "메시지"
|
|
77
|
-
console.log(" 계정 연결: pinglet login (
|
|
76
|
+
console.log(' 메시지 작성: pinglet login 후 pinglet post "메시지"');
|
|
77
|
+
console.log(" 계정 연결: pinglet login (메시지 작성/반응에 필요)");
|
|
78
78
|
}
|
package/dist/commands/login.js
CHANGED
|
@@ -82,7 +82,7 @@ async function runLogin(args) {
|
|
|
82
82
|
(0, config_1.saveConfig)(config);
|
|
83
83
|
console.log("✓ 로그인 정보 저장 완료");
|
|
84
84
|
for (const [agentType, record] of Object.entries(config.installations)) {
|
|
85
|
-
const linked = await (0, api_1.linkInstallation)(config, record.
|
|
85
|
+
const linked = await (0, api_1.linkInstallation)(config, record.token);
|
|
86
86
|
console.log(linked
|
|
87
87
|
? `✓ ${agentType} installation을 계정에 연결했습니다`
|
|
88
88
|
: `○ ${agentType} installation 연결 실패 — 서버 연결 후 다시 시도하세요`);
|
package/dist/commands/post.js
CHANGED
|
@@ -10,6 +10,7 @@ const REASON_LABELS = {
|
|
|
10
10
|
URL_NOT_ALLOWED: "URL은 허용되지 않습니다",
|
|
11
11
|
PII_DETECTED: "이메일/전화번호 등 개인정보가 포함되어 있습니다",
|
|
12
12
|
BANNED_WORD: "정책상 검토가 필요한 표현이 포함되어 있습니다",
|
|
13
|
+
CONTROL_CHARS: "제어 문자(escape sequence)는 허용되지 않습니다",
|
|
13
14
|
};
|
|
14
15
|
/**
|
|
15
16
|
* `pinglet post "메시지" [--category <카테고리>] [--quiet]`
|
|
@@ -40,8 +41,11 @@ async function runPost(args) {
|
|
|
40
41
|
const result = await (0, api_1.createMessage)(config, text, category);
|
|
41
42
|
if (!result.ok) {
|
|
42
43
|
switch (result.error) {
|
|
44
|
+
case "LOGIN_REQUIRED":
|
|
45
|
+
console.log("✗ 메시지 작성에는 GitHub 로그인이 필요합니다. `pinglet login`을 실행하세요 (읽기는 로그인 없이 가능).");
|
|
46
|
+
break;
|
|
43
47
|
case "UNAUTHORIZED":
|
|
44
|
-
console.log("✗
|
|
48
|
+
console.log("✗ 로그인이 만료됐습니다. `pinglet login`으로 다시 로그인하세요.");
|
|
45
49
|
break;
|
|
46
50
|
case "RATE_LIMITED":
|
|
47
51
|
console.log("✗ 작성 제한(분당 5개)에 걸렸습니다. 잠시 후 다시 시도하세요.");
|
|
@@ -67,9 +71,6 @@ async function runPost(args) {
|
|
|
67
71
|
console.log("✓ 메시지가 등록됐습니다. 곧 다른 개발자들의 터미널에 표시됩니다.");
|
|
68
72
|
if (!quiet) {
|
|
69
73
|
console.log(` id: ${result.id}`);
|
|
70
|
-
if (!config.userToken) {
|
|
71
|
-
console.log(" '익명의 개발자'로 표시됩니다 — 닉네임을 달려면 `pinglet login`");
|
|
72
|
-
}
|
|
73
74
|
}
|
|
74
75
|
break;
|
|
75
76
|
case "PENDING_REVIEW":
|
package/dist/render.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.supportsUnicode = supportsUnicode;
|
|
4
|
+
exports.sanitizeForTerminal = sanitizeForTerminal;
|
|
4
5
|
exports.formatPing = formatPing;
|
|
5
6
|
exports.formatMyReach = formatMyReach;
|
|
6
7
|
/** 터미널 독립 원칙: unicode가 불확실하면 plain ASCII로 fallback (아키텍처 §5). */
|
|
@@ -10,18 +11,30 @@ function supportsUnicode() {
|
|
|
10
11
|
const locale = process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || "";
|
|
11
12
|
return /utf-?8/i.test(locale);
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* 터미널에 그리기 전 최종 방어선: 제어·서식 문자를 제거한다.
|
|
16
|
+
* 서버 moderation이 이미 거르지만, 구버전 서버·손상된 캐시를 신뢰하지 않는다
|
|
17
|
+
* (ANSI escape가 살아 있으면 수신자 터미널을 조작할 수 있다).
|
|
18
|
+
*/
|
|
19
|
+
function sanitizeForTerminal(text) {
|
|
20
|
+
return text
|
|
21
|
+
.replace(/\s+/g, " ")
|
|
22
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, "")
|
|
23
|
+
.trim();
|
|
24
|
+
}
|
|
13
25
|
/** 작성자 닉네임 없이 메시지만 표시한다 (spinner verb용). */
|
|
14
26
|
function formatPing(message) {
|
|
15
27
|
const sponsored = message.contentType === "SPONSORED" ? "[AD] " : "";
|
|
28
|
+
const text = sanitizeForTerminal(message.text);
|
|
16
29
|
if (supportsUnicode()) {
|
|
17
|
-
return `💌 ${sponsored}"${
|
|
30
|
+
return `💌 ${sponsored}"${text}"`;
|
|
18
31
|
}
|
|
19
|
-
return `[ping] ${sponsored}"${
|
|
32
|
+
return `[ping] ${sponsored}"${text}"`;
|
|
20
33
|
}
|
|
21
34
|
const REACH_TEXT_MAX = 24;
|
|
22
35
|
/** statusline용: 내가 쓴 최근 메시지의 도달 현황 한 줄 (커버리지 = 도달/활성 설치). */
|
|
23
36
|
function formatMyReach(stats) {
|
|
24
|
-
let text = stats.text;
|
|
37
|
+
let text = sanitizeForTerminal(stats.text);
|
|
25
38
|
if (text.length > REACH_TEXT_MAX)
|
|
26
39
|
text = text.slice(0, REACH_TEXT_MAX - 1) + "…";
|
|
27
40
|
const reached = stats.reachedInstallations;
|