pinglet-cli 0.5.3 → 0.6.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.ja.md CHANGED
@@ -74,7 +74,7 @@ statusline の言語はシステム言語に応じて自動選択されます(
74
74
  | `pinglet post "メッセージ"` | Ping を送る |
75
75
  | `pinglet ping` | いま表示されるメッセージをプレビュー |
76
76
  | `pinglet doctor` | インストール・接続状態を診断 |
77
- | `pinglet uninstall` | 接続解除と元の設定の復元 |
77
+ | `pinglet uninstall` | 設定復元、サーバー上のインストール失効とログアウト |
78
78
 
79
79
  ## 安心して使えます
80
80
 
@@ -97,6 +97,13 @@ npm uninstall -g pinglet-cli
97
97
 
98
98
  ローカルデータ(`~/.pinglet`)まで削除するには `pinglet uninstall --purge`。
99
99
 
100
+ `uninstall` は端末のインストールトークンと現在のログインセッションも失効させます。
101
+ `logout` は現在のセッションと端末のアカウント連携を解除し、連携設定と匿名閲覧は維持します。
102
+ サーバー接続や設定復元に失敗した場合、再試行に必要なデータを保持し、失敗を表示します。
103
+ `--purge` はすべての解除に成功した後に実行します。npm 本体は削除しません。
104
+
105
+ フィードキャッシュは更新後最大10分まで使用し、有効期限を過ぎたメッセージは表示しません。
106
+
100
107
  ## 利用規約とプライバシーポリシー
101
108
 
102
109
  ログインしてメッセージを投稿すると、以下の文書に同意したものとみなされます。
package/README.ko.md CHANGED
@@ -74,7 +74,7 @@ statusline 언어는 시스템 언어에 따라 자동 선택됩니다(한국어
74
74
  | `pinglet post "메시지"` | Ping 보내기 |
75
75
  | `pinglet ping` | 지금 표시될 메시지 미리보기 |
76
76
  | `pinglet doctor` | 설치·연결 상태 진단 |
77
- | `pinglet uninstall` | 연결 해제 및 기존 설정 복원 |
77
+ | `pinglet uninstall` | 기존 설정 복원, 서버 설치 폐기 및 로그아웃 |
78
78
 
79
79
  ## 안심하고 쓰세요
80
80
 
@@ -98,6 +98,13 @@ npm uninstall -g pinglet-cli
98
98
 
99
99
  로컬 데이터(`~/.pinglet`)까지 지우려면 `pinglet uninstall --purge`.
100
100
 
101
+ `uninstall`은 서버의 설치 토큰과 현재 로그인 세션도 폐기합니다. `logout`은
102
+ 현재 세션을 폐기하고 이 기기의 설치–계정 연결을 해제하되, 익명 읽기와 연동은 유지합니다.
103
+ 서버 연결이나 설정 복원에 실패하면 완료로 처리하지 않으며 재시도에 필요한 데이터를 보존합니다.
104
+ `--purge`는 모든 해제가 성공한 뒤에만 실행됩니다. npm 프로그램 자체는 삭제하지 않습니다.
105
+
106
+ 피드는 마지막 갱신 후 최대 10분만 사용하며, 메시지 만료 시각이 지나면 더 이상 표시하지 않습니다.
107
+
101
108
  ## 약관 및 개인정보처리방침
102
109
 
103
110
  로그인하여 메시지를 작성하면 아래 문서에 동의한 것으로 간주됩니다.
package/README.md CHANGED
@@ -74,7 +74,7 @@ left untouched). To opt out, add `"autoUpdate": false` to `~/.pinglet/config.jso
74
74
  | `pinglet post "message"` | Send a Ping |
75
75
  | `pinglet ping` | Preview the message that would be shown now |
76
76
  | `pinglet doctor` | Diagnose installation and connection status |
77
- | `pinglet uninstall` | Disconnect and restore your previous settings |
77
+ | `pinglet uninstall` | Restore settings, revoke installations and sign out |
78
78
 
79
79
  ## Safe to use
80
80
 
@@ -99,6 +99,14 @@ npm uninstall -g pinglet-cli
99
99
 
100
100
  To also remove local data (`~/.pinglet`), run `pinglet uninstall --purge`.
101
101
 
102
+ `uninstall` also revokes this device's installation tokens and current login session.
103
+ `logout` revokes the current session and unlinks this device from the account, keeping
104
+ the integration and anonymous reading. If server cleanup or settings restoration fails,
105
+ the command reports failure and preserves the data needed to retry. `--purge` runs only
106
+ after successful cleanup. Neither command removes npm itself.
107
+
108
+ Cached feeds are used for at most 10 minutes and expired messages are no longer displayed.
109
+
102
110
  ## Terms & Privacy
103
111
 
104
112
  By logging in and posting messages you agree to the following:
@@ -47,6 +47,7 @@ const child_process_1 = require("child_process");
47
47
  const config_1 = require("../config");
48
48
  const cache_1 = require("../cache");
49
49
  const render_1 = require("../render");
50
+ const storage_1 = require("../storage");
50
51
  const CLAUDE_DIR = path.join(os.homedir(), ".claude");
51
52
  const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
52
53
  const COMMANDS_DIR = path.join(CLAUDE_DIR, "commands");
@@ -160,8 +161,9 @@ function uninstallSlashCommand() {
160
161
  fs.unlinkSync(file);
161
162
  removed = true;
162
163
  }
163
- catch {
164
- // 없거나 읽을 수 없는 파일은 건너뛴다.
164
+ catch (error) {
165
+ if (!(0, storage_1.isMissing)(error))
166
+ throw error;
165
167
  }
166
168
  }
167
169
  return removed;
@@ -194,20 +196,28 @@ function settingsMtimeMs() {
194
196
  try {
195
197
  return fs.statSync(SETTINGS_PATH).mtimeMs;
196
198
  }
197
- catch {
198
- return null; // 파일 없음
199
+ catch (error) {
200
+ if (!(0, storage_1.isMissing)(error))
201
+ throw error;
202
+ return null;
199
203
  }
200
204
  }
201
205
  function readSettingsWithMtime() {
202
206
  const mtimeMs = settingsMtimeMs();
203
207
  try {
208
+ const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8"));
209
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
210
+ throw new Error("Expected a settings object");
211
+ }
204
212
  return {
205
- settings: JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8")),
213
+ settings: settings,
206
214
  mtimeMs,
207
215
  };
208
216
  }
209
- catch {
210
- return { settings: {}, mtimeMs };
217
+ catch (error) {
218
+ if ((0, storage_1.isMissing)(error) && mtimeMs === null)
219
+ return { settings: {}, mtimeMs };
220
+ throw new Error(`Cannot read ${SETTINGS_PATH}; original settings preserved.`, { cause: error });
211
221
  }
212
222
  }
213
223
  /**
@@ -227,6 +237,10 @@ function writeSettings(settings, expectedMtimeMs) {
227
237
  const tmp = `${SETTINGS_PATH}.pinglet-tmp.${process.pid}`;
228
238
  try {
229
239
  fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
240
+ if (settingsMtimeMs() !== expectedMtimeMs) {
241
+ fs.unlinkSync(tmp);
242
+ return false;
243
+ }
230
244
  fs.renameSync(tmp, SETTINGS_PATH);
231
245
  }
232
246
  catch (error) {
@@ -273,10 +287,7 @@ function cleanupSpinnerTips(settings, config) {
273
287
  else {
274
288
  delete settings.spinnerTipsOverride;
275
289
  }
276
- if (adapter?.spinnerTipsBackup !== undefined) {
277
- delete adapter.spinnerTipsBackup;
278
- (0, config_1.saveConfig)(config);
279
- }
290
+ // Keep the backup until uninstall commits successfully.
280
291
  return true;
281
292
  }
282
293
  /**
@@ -402,6 +413,7 @@ function installClaudeIntegration(config, options = {}) {
402
413
  }
403
414
  function uninstallClaudeIntegration(config) {
404
415
  let settingsChanged = false;
416
+ let committed = false;
405
417
  for (let attempt = 0; attempt < 3; attempt++) {
406
418
  const { settings, mtimeMs } = readSettingsWithMtime();
407
419
  settingsChanged = false;
@@ -450,11 +462,13 @@ function uninstallClaudeIntegration(config) {
450
462
  }
451
463
  settingsChanged = true;
452
464
  }
453
- if (!settingsChanged)
465
+ if (!settingsChanged || writeSettings(settings, mtimeMs)) {
466
+ committed = true;
454
467
  break;
455
- if (writeSettings(settings, mtimeMs))
456
- break; // 충돌 시 재읽기 후 재적용
468
+ }
457
469
  }
470
+ if (!committed)
471
+ throw new Error((0, i18n_1.t)("claude.settingsBusy"));
458
472
  const commandRemoved = uninstallSlashCommand();
459
473
  if (config.adapters.claude) {
460
474
  delete config.adapters.claude;
@@ -43,6 +43,7 @@ const os = __importStar(require("os"));
43
43
  const path = __importStar(require("path"));
44
44
  const child_process_1 = require("child_process");
45
45
  const config_1 = require("../config");
46
+ const storage_1 = require("../storage");
46
47
  const CODEX_DIR = path.join(os.homedir(), ".codex");
47
48
  const CONFIG_TOML_PATH = path.join(CODEX_DIR, "config.toml");
48
49
  /**
@@ -71,8 +72,10 @@ function readToml() {
71
72
  try {
72
73
  return fs.readFileSync(CONFIG_TOML_PATH, "utf8");
73
74
  }
74
- catch {
75
- return "";
75
+ catch (error) {
76
+ if ((0, storage_1.isMissing)(error))
77
+ return "";
78
+ throw error;
76
79
  }
77
80
  }
78
81
  /**
@@ -94,7 +97,13 @@ function findNotifyEntry(toml) {
94
97
  let depth = 0;
95
98
  for (; i < toml.length; i++) {
96
99
  const ch = toml[i];
97
- if (ch === '"' || ch === "'") {
100
+ if (ch === "#") {
101
+ const end = toml.indexOf("\n", i);
102
+ if (end === -1)
103
+ break;
104
+ i = end;
105
+ }
106
+ else if (ch === '"' || ch === "'") {
98
107
  const quote = ch;
99
108
  i++;
100
109
  while (i < toml.length && toml[i] !== quote) {
@@ -112,8 +121,7 @@ function findNotifyEntry(toml) {
112
121
  return toml.slice(start, i + 1);
113
122
  }
114
123
  }
115
- // 괄호 짝이 안 맞음 — 이미 깨진 파일이므로 건드리지 않는다.
116
- return null;
124
+ throw new Error(`Invalid notify array in ${CONFIG_TOML_PATH}; original settings preserved.`);
117
125
  }
118
126
  function isCodexIntegrationInstalled() {
119
127
  const entry = findNotifyEntry(readToml());
@@ -148,7 +156,7 @@ function installCodexIntegration(config, options = {}) {
148
156
  next = notifyLine() + "\n" + toml;
149
157
  }
150
158
  fs.mkdirSync(CODEX_DIR, { recursive: true });
151
- fs.writeFileSync(CONFIG_TOML_PATH, next);
159
+ (0, storage_1.atomicWrite)(CONFIG_TOML_PATH, next);
152
160
  return { ok: true };
153
161
  }
154
162
  function uninstallCodexIntegration(config) {
@@ -156,13 +164,18 @@ function uninstallCodexIntegration(config) {
156
164
  const existing = findNotifyEntry(toml);
157
165
  const isOurs = existing !== null &&
158
166
  (existing.includes("pinglet") || existing.includes((0, config_1.cliPath)()));
159
- if (!existing || !isOurs)
167
+ if (!existing || !isOurs) {
168
+ if (config.adapters.codex) {
169
+ delete config.adapters.codex;
170
+ (0, config_1.saveConfig)(config);
171
+ }
160
172
  return false;
173
+ }
161
174
  const backup = config.adapters.codex?.notifyBackup;
162
175
  const next = backup
163
176
  ? toml.replace(existing, backup)
164
177
  : toml.replace(existing + "\n", "").replace(existing, "");
165
- fs.writeFileSync(CONFIG_TOML_PATH, next);
178
+ (0, storage_1.atomicWrite)(CONFIG_TOML_PATH, next);
166
179
  delete config.adapters.codex;
167
180
  (0, config_1.saveConfig)(config);
168
181
  return true;
package/dist/api.js CHANGED
@@ -40,6 +40,8 @@ exports.fetchFeed = fetchFeed;
40
40
  exports.uploadEventBatch = uploadEventBatch;
41
41
  exports.sendHeartbeat = sendHeartbeat;
42
42
  exports.createMessage = createMessage;
43
+ exports.validateUserToken = validateUserToken;
44
+ exports.releaseCredential = releaseCredential;
43
45
  exports.linkInstallation = linkInstallation;
44
46
  const os = __importStar(require("os"));
45
47
  const crypto_1 = require("crypto");
@@ -100,6 +102,7 @@ async function fetchFeed(config, record, limit = 50) {
100
102
  text: item.text,
101
103
  author: item.authorNickname || "익명의 개발자",
102
104
  category: item.category,
105
+ expiresAt: item.expiresAt,
103
106
  contentType: item.contentType === "SYSTEM" || item.contentType === "SPONSORED"
104
107
  ? item.contentType
105
108
  : "USER",
@@ -136,7 +139,7 @@ async function sendHeartbeat(config, record) {
136
139
  * POST /messages — 메시지 작성 (분당 5개 제한).
137
140
  * 작성은 로그인(GitHub 또는 Google)이 필요하다 (읽기는 계속 익명).
138
141
  */
139
- async function createMessage(config, text, category) {
142
+ async function createMessage(config, text, category, requestId = (0, crypto_1.randomUUID)()) {
140
143
  const token = config.userToken;
141
144
  if (!token)
142
145
  return { ok: false, error: "LOGIN_REQUIRED" };
@@ -147,7 +150,7 @@ async function createMessage(config, text, category) {
147
150
  "content-type": "application/json",
148
151
  authorization: `Bearer ${token}`,
149
152
  },
150
- body: JSON.stringify({ text, ...(category && { category }) }),
153
+ body: JSON.stringify({ text, requestId, ...(category && { category }) }),
151
154
  signal: AbortSignal.timeout(TIMEOUT_MS),
152
155
  });
153
156
  if (res.status === 401)
@@ -168,6 +171,32 @@ async function createMessage(config, text, category) {
168
171
  return { ok: false, error: "NETWORK" };
169
172
  }
170
173
  }
174
+ async function validateUserToken(config, token) {
175
+ const user = await request(config, "GET", "/users/me", { token });
176
+ return typeof user?.id === "string" && user.id.length > 0;
177
+ }
178
+ /** Revocation/unlink are idempotent. A 401 installation credential is already unusable.
179
+ * Other failures (including an old server's 404) must not be reported as success.
180
+ */
181
+ async function releaseCredential(config, kind, token) {
182
+ try {
183
+ const apiPath = kind === "user" ? "/auth/logout" : kind === "unlink" ? "/installations/unlink" : "/installations/current";
184
+ const res = await fetch(config.apiBaseUrl + apiPath, {
185
+ method: kind === "installation" ? "DELETE" : "POST",
186
+ headers: { authorization: `Bearer ${token}` },
187
+ signal: AbortSignal.timeout(TIMEOUT_MS),
188
+ });
189
+ if (res.status === 401)
190
+ return true;
191
+ if (!res.ok)
192
+ return false;
193
+ const body = await res.json();
194
+ return kind === "unlink" ? body.unlinked === true : body.revoked === true;
195
+ }
196
+ catch {
197
+ return false;
198
+ }
199
+ }
171
200
  /**
172
201
  * POST /installations/link — 로그인 후 기존 익명 설치를 계정에 연결한다.
173
202
  * 소유 증명으로 installation "토큰"을 보낸다 (ID만으로는 연결 불가).
package/dist/cache.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_FEED_AGE_MS = void 0;
3
4
  exports.loadFeedMessages = loadFeedMessages;
4
5
  exports.saveFeedMessages = saveFeedMessages;
5
6
  exports.feedAgeMs = feedAgeMs;
@@ -15,9 +16,16 @@ const config_1 = require("./config");
15
16
  * 서버 feed를 아직 못 받았으면 빈 배열 — Pinglet은 아무것도 표시하지 않고
16
17
  * Claude Code 기본 spinner가 그대로 유지된다.
17
18
  */
19
+ exports.MAX_FEED_AGE_MS = 10 * 60_000;
18
20
  function loadFeedMessages() {
19
21
  const cache = (0, config_1.readJsonFile)(config_1.FEED_PATH);
20
- return cache?.messages ?? [];
22
+ if (!cache || !Array.isArray(cache.messages))
23
+ return [];
24
+ const fetchedAt = Date.parse(cache.fetchedAt);
25
+ const now = Date.now();
26
+ if (!Number.isFinite(fetchedAt) || fetchedAt > now || now - fetchedAt >= exports.MAX_FEED_AGE_MS)
27
+ return [];
28
+ return cache.messages.filter((m) => m.expiresAt == null || Date.parse(m.expiresAt) > now);
21
29
  }
22
30
  function saveFeedMessages(messages) {
23
31
  const file = {
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ const refresh_1 = require("./commands/refresh");
14
14
  const ping_1 = require("./commands/ping");
15
15
  const post_1 = require("./commands/post");
16
16
  const i18n_1 = require("./i18n");
17
+ const lock_1 = require("./lock");
17
18
  const HELP = (0, i18n_1.t)("cli.help", { version: config_1.VERSION });
18
19
  async function main() {
19
20
  const [command, ...rest] = process.argv.slice(2);
@@ -22,13 +23,13 @@ async function main() {
22
23
  await (0, install_1.runInstall)(rest);
23
24
  break;
24
25
  case "uninstall":
25
- (0, uninstall_1.runUninstall)(rest);
26
+ await (0, uninstall_1.runUninstall)(rest);
26
27
  break;
27
28
  case "login":
28
29
  await (0, login_1.runLogin)(rest);
29
30
  break;
30
31
  case "logout":
31
- (0, logout_1.runLogout)();
32
+ await (0, logout_1.runLogout)();
32
33
  break;
33
34
  case "doctor":
34
35
  await (0, doctor_1.runDoctor)();
@@ -72,7 +73,9 @@ async function main() {
72
73
  }
73
74
  }
74
75
  }
75
- main().catch((error) => {
76
+ (0, lock_1.withPingletLock)(main, {
77
+ skipIfBusy: ["statusline", "notify", "refresh", "flush"].includes(process.argv[2]),
78
+ }).catch((error) => {
76
79
  // statusline/notify는 조용히 실패해야 Claude/Codex 경험을 해치지 않는다.
77
80
  const command = process.argv[2];
78
81
  if (command === "statusline" || command === "notify") {
@@ -1,7 +1,41 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.runFlush = runFlush;
4
37
  const config_1 = require("../config");
38
+ const fs = __importStar(require("fs"));
5
39
  const api_1 = require("../api");
6
40
  const queue_1 = require("../queue");
7
41
  /**
@@ -10,6 +44,8 @@ const queue_1 = require("../queue");
10
44
  * 재전송 중복은 서버가 eventId로 dedupe한다.
11
45
  */
12
46
  async function runFlush() {
47
+ if (!fs.existsSync(config_1.CONFIG_PATH))
48
+ return;
13
49
  const config = (0, config_1.loadConfig)();
14
50
  const events = (0, queue_1.readEvents)();
15
51
  if (events.length === 0)
@@ -58,7 +58,9 @@ function openBrowser(url) {
58
58
  ? ["cmd", "/c", "start", "", url]
59
59
  : ["xdg-open", url];
60
60
  try {
61
- (0, child_process_1.spawn)(cmd[0], cmd.slice(1), { stdio: "ignore" }).unref();
61
+ const child = (0, child_process_1.spawn)(cmd[0], cmd.slice(1), { stdio: "ignore" });
62
+ child.on("error", () => { });
63
+ child.unref();
62
64
  return true;
63
65
  }
64
66
  catch {
@@ -106,7 +108,7 @@ function loginViaLoopback(apiBaseUrl, provider, quiet) {
106
108
  resolve(token);
107
109
  };
108
110
  const timer = setTimeout(() => done(null), LOGIN_TIMEOUT_MS);
109
- const server = http.createServer((req, res) => {
111
+ const server = http.createServer(async (req, res) => {
110
112
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
111
113
  if (url.pathname !== "/callback") {
112
114
  res.statusCode = 404;
@@ -115,8 +117,11 @@ function loginViaLoopback(apiBaseUrl, provider, quiet) {
115
117
  }
116
118
  const token = url.searchParams.get("token");
117
119
  res.setHeader("content-type", "text/html; charset=utf-8");
118
- res.end(RESULT_HTML(Boolean(token)));
119
- done(token);
120
+ const valid = Boolean(token) && await (0, api_1.validateUserToken)({ apiBaseUrl }, token);
121
+ res.statusCode = valid ? 200 : 401;
122
+ res.end(RESULT_HTML(valid));
123
+ if (valid)
124
+ done(token);
120
125
  });
121
126
  server.on("error", () => done(null));
122
127
  server.listen(0, "127.0.0.1", () => {
@@ -172,6 +177,17 @@ async function runLogin(args) {
172
177
  process.exitCode = 1;
173
178
  return;
174
179
  }
180
+ if (!await (0, api_1.validateUserToken)(config, token)) {
181
+ console.log((0, i18n_1.t)("login.invalidToken"));
182
+ if (!quiet)
183
+ process.exitCode = 1;
184
+ return;
185
+ }
186
+ if (config.userToken && config.userToken !== token) {
187
+ if (!await (0, api_1.releaseCredential)(config, "user", config.userToken)) {
188
+ throw new Error((0, i18n_1.t)("logout.failed"));
189
+ }
190
+ }
175
191
  config.userToken = token;
176
192
  (0, config_1.saveConfig)(config);
177
193
  console.log((0, i18n_1.t)("login.saved"));
@@ -3,17 +3,25 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runLogout = runLogout;
4
4
  const config_1 = require("../config");
5
5
  const i18n_1 = require("../i18n");
6
+ const api_1 = require("../api");
6
7
  /**
7
- * `pinglet logout` — 로컬에 저장된 로그인 토큰만 지운다.
8
- * 통합 설정·installation·캐시는 그대로라 읽기는 계속 되고, 다시 `pinglet login`으로
9
- * 다른 계정(GitHub/Google)을 연결할 수 있다. 서버 측 토큰 폐기는 하지 않는다 (JWT 만료까지 유효).
8
+ * Revoke the user session and unlink this device's installations before
9
+ * removing local credentials. On failure preserve credentials for retry.
10
10
  */
11
- function runLogout() {
11
+ async function runLogout() {
12
12
  const config = (0, config_1.loadConfig)();
13
+ for (const record of Object.values(config.installations)) {
14
+ if (!await (0, api_1.releaseCredential)(config, "unlink", record.token)) {
15
+ throw new Error((0, i18n_1.t)("logout.failed"));
16
+ }
17
+ }
13
18
  if (!config.userToken) {
14
19
  console.log((0, i18n_1.t)("logout.notLoggedIn"));
15
20
  return;
16
21
  }
22
+ if (!await (0, api_1.releaseCredential)(config, "user", config.userToken)) {
23
+ throw new Error((0, i18n_1.t)("logout.failed"));
24
+ }
17
25
  delete config.userToken;
18
26
  (0, config_1.saveConfig)(config);
19
27
  console.log((0, i18n_1.t)("logout.done"));
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.runNotify = runNotify;
4
37
  const child_process_1 = require("child_process");
@@ -6,12 +39,16 @@ const cache_1 = require("../cache");
6
39
  const picker_1 = require("../picker");
7
40
  const queue_1 = require("../queue");
8
41
  const runtime_1 = require("../runtime");
42
+ const fs = __importStar(require("fs"));
43
+ const config_1 = require("../config");
9
44
  /**
10
45
  * Codex CLI notify hook 진입점 (experimental).
11
46
  * Codex는 turn 완료 시 마지막 인자로 notification JSON을 넘긴다.
12
47
  * notify 프로세스는 TUI 밖에서 돌기 때문에 MVP에서는 macOS 알림으로 Ping을 전달한다.
13
48
  */
14
49
  function runNotify(args) {
50
+ if (!fs.existsSync(config_1.CONFIG_PATH) || !(0, config_1.loadConfig)().adapters.codex)
51
+ return;
15
52
  let notificationType;
16
53
  try {
17
54
  const payload = JSON.parse(args[args.length - 1] ?? "");
@@ -5,6 +5,7 @@ const config_1 = require("../config");
5
5
  const api_1 = require("../api");
6
6
  const cache_1 = require("../cache");
7
7
  const i18n_1 = require("../i18n");
8
+ const pending_post_1 = require("../pending-post");
8
9
  const REASON_KEYS = {
9
10
  EMPTY: "reason.EMPTY",
10
11
  TOO_LONG: "reason.TOO_LONG",
@@ -40,7 +41,10 @@ async function runPost(args) {
40
41
  return;
41
42
  }
42
43
  const config = (0, config_1.loadConfig)();
43
- const result = await (0, api_1.createMessage)(config, text, category);
44
+ const requestId = (0, pending_post_1.pendingPostId)(config, text, category);
45
+ const result = await (0, api_1.createMessage)(config, text, category, requestId);
46
+ if (result.ok || (result.error !== "NETWORK" && result.error !== "SERVER"))
47
+ (0, pending_post_1.completePost)(requestId);
44
48
  // 슬래시 명령 안에서는 같은 방식의 슬래시 명령을 안내한다.
45
49
  const loginCmd = quiet ? "/pinglet-login" : "pinglet login";
46
50
  if (!result.ok) {
@@ -1,7 +1,41 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.runRefresh = runRefresh;
4
37
  const config_1 = require("../config");
38
+ const fs = __importStar(require("fs"));
5
39
  const api_1 = require("../api");
6
40
  const update_1 = require("../update");
7
41
  const cache_1 = require("../cache");
@@ -11,6 +45,8 @@ const claude_1 = require("../adapters/claude");
11
45
  * 설치 당시 오프라인이었다면 installation 등록도 여기서 재시도한다.
12
46
  */
13
47
  async function runRefresh() {
48
+ if (!fs.existsSync(config_1.CONFIG_PATH))
49
+ return;
14
50
  const config = (0, config_1.loadConfig)();
15
51
  const wanted = [];
16
52
  if (config.adapters.claude)
@@ -26,7 +62,7 @@ async function runRefresh() {
26
62
  }
27
63
  }
28
64
  }
29
- const records = Object.values(config.installations);
65
+ const records = wanted.flatMap((agent) => config.installations[agent] ? [config.installations[agent]] : []);
30
66
  if (records.length === 0)
31
67
  return;
32
68
  const onlineCounts = await Promise.all(records.map((r) => (0, api_1.sendHeartbeat)(config, r)));
@@ -135,11 +135,14 @@ function rotateSpinner(state, now) {
135
135
  * - flush/refresh 백그라운드 트리거 (Claude 사용 중 항상 호출되므로 이 경로에서)
136
136
  */
137
137
  function runStatusline() {
138
+ if (!fs.existsSync(config_1.CONFIG_PATH) || !(0, config_1.loadConfig)().adapters.claude)
139
+ return;
138
140
  const payload = readPayload();
139
141
  const state = (0, cache_1.loadState)();
140
142
  const now = Date.now();
141
143
  trackSpinnerActivity(state, payload);
142
- const rotateDue = !state.current || now - state.current.shownAt >= runtime_1.ROTATE_MS;
144
+ const rotateDue = !state.current || now - state.current.shownAt >= runtime_1.ROTATE_MS ||
145
+ !(0, cache_1.loadFeedMessages)().some((message) => message.id === state.current?.messageId);
143
146
  // 300ms마다 호출되므로 저장/maintenance는 1초 스로틀. 회전 시점에는 항상 저장.
144
147
  const saveDue = rotateDue ||
145
148
  state.lastTickAt === undefined ||
@@ -39,7 +39,8 @@ const config_1 = require("../config");
39
39
  const claude_1 = require("../adapters/claude");
40
40
  const codex_1 = require("../adapters/codex");
41
41
  const i18n_1 = require("../i18n");
42
- function runUninstall(args) {
42
+ const api_1 = require("../api");
43
+ async function runUninstall(args) {
43
44
  const config = (0, config_1.loadConfig)();
44
45
  const claudeRemoved = (0, claude_1.uninstallClaudeIntegration)(config);
45
46
  const codexRemoved = (0, codex_1.uninstallCodexIntegration)(config);
@@ -49,6 +50,20 @@ function runUninstall(args) {
49
50
  console.log(codexRemoved
50
51
  ? "✓ Codex integration removed"
51
52
  : "○ Codex integration not installed");
53
+ for (const [agent, record] of Object.entries(config.installations)) {
54
+ if (!await (0, api_1.releaseCredential)(config, "installation", record.token)) {
55
+ throw new Error((0, i18n_1.t)("uninstall.failed"));
56
+ }
57
+ delete config.installations[agent];
58
+ (0, config_1.saveConfig)(config);
59
+ }
60
+ if (config.userToken) {
61
+ if (!await (0, api_1.releaseCredential)(config, "user", config.userToken)) {
62
+ throw new Error((0, i18n_1.t)("uninstall.failed"));
63
+ }
64
+ delete config.userToken;
65
+ (0, config_1.saveConfig)(config);
66
+ }
52
67
  if (args.includes("--purge")) {
53
68
  fs.rmSync(config_1.PINGLET_DIR, { recursive: true, force: true });
54
69
  console.log("✓ ~/.pinglet removed (config / cache / event queue)");
@@ -56,4 +71,5 @@ function runUninstall(args) {
56
71
  else {
57
72
  console.log((0, i18n_1.t)("uninstall.keep"));
58
73
  }
74
+ console.log((0, i18n_1.t)("uninstall.npm"));
59
75
  }
package/dist/config.js CHANGED
@@ -43,6 +43,7 @@ exports.cliPath = cliPath;
43
43
  const fs = __importStar(require("fs"));
44
44
  const os = __importStar(require("os"));
45
45
  const path = __importStar(require("path"));
46
+ const storage_1 = require("./storage");
46
47
  /** package.json의 version — 하드코딩하면 npm version bump와 어긋난다. */
47
48
  exports.VERSION = (() => {
48
49
  try {
@@ -66,25 +67,36 @@ exports.ONLINE_PATH = path.join(exports.PINGLET_DIR, "online.json");
66
67
  exports.UPDATE_PATH = path.join(exports.PINGLET_DIR, "update.json");
67
68
  exports.DEFAULT_API_BASE_URL = process.env.PINGLET_API_URL ?? "https://pinglet.halluci.co.kr";
68
69
  function ensurePingletDir() {
69
- fs.mkdirSync(exports.PINGLET_DIR, { recursive: true });
70
+ fs.mkdirSync(exports.PINGLET_DIR, { recursive: true, mode: 0o700 });
70
71
  }
71
- function readJsonFile(filePath) {
72
+ function readJsonFile(filePath, strict = false) {
72
73
  try {
73
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
74
+ const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
75
+ if (strict && (!value || typeof value !== "object" || Array.isArray(value))) {
76
+ throw new Error("Expected a JSON object");
77
+ }
78
+ return value;
74
79
  }
75
- catch {
80
+ catch (error) {
81
+ if (strict && !(0, storage_1.isMissing)(error))
82
+ throw new Error(`Cannot read ${filePath}; original file preserved.`, { cause: error });
76
83
  return null;
77
84
  }
78
85
  }
79
86
  function writeJsonFile(filePath, value) {
80
87
  ensurePingletDir();
81
- const tmp = filePath + ".tmp";
82
- fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
83
- fs.renameSync(tmp, filePath);
88
+ (0, storage_1.atomicWrite)(filePath, JSON.stringify(value, null, 2));
84
89
  }
85
90
  function loadConfig() {
86
- const existing = readJsonFile(exports.CONFIG_PATH);
91
+ const existing = readJsonFile(exports.CONFIG_PATH, true);
87
92
  if (existing) {
93
+ const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
94
+ if (typeof existing.apiBaseUrl !== "string" || typeof existing.createdAt !== "string" ||
95
+ (existing.userToken !== undefined && typeof existing.userToken !== "string") ||
96
+ (existing.installations !== undefined && !object(existing.installations)) ||
97
+ (existing.adapters !== undefined && !object(existing.adapters))) {
98
+ throw new Error(`Invalid ${exports.CONFIG_PATH}; original file preserved.`);
99
+ }
88
100
  existing.installations ??= {};
89
101
  existing.adapters ??= {};
90
102
  return existing;
package/dist/i18n.js CHANGED
@@ -11,7 +11,7 @@ Usage: pinglet <command>
11
11
 
12
12
  install [--api <url>] [--force] Install the Claude Code integration
13
13
  [--claude | --codex] Codex uses OS notifications; opt in with --codex (experimental)
14
- uninstall [--purge] Remove the integration (--purge: also delete ~/.pinglet)
14
+ uninstall [--purge] Restore settings and revoke credentials (--purge: delete ~/.pinglet)
15
15
  login [--github | --google] Link your account (GitHub or Google; pick in the browser if omitted)
16
16
  [--token <jwt>]
17
17
  logout Sign out (keeps the integration and cache)
@@ -43,6 +43,10 @@ Internal commands (called by the integration):
43
43
  "login.html.failSub": "Please try again from the terminal.",
44
44
  // ---- logout
45
45
  "logout.notLoggedIn": "○ You are not logged in.",
46
+ "logout.failed": "Server sign-out failed. Credentials are preserved; retry pinglet logout when connected.",
47
+ "uninstall.failed": "Server revocation failed. Local data is preserved; retry pinglet uninstall when connected.",
48
+ "uninstall.npm": "To remove the CLI package too, run: npm uninstall -g pinglet-cli (npm itself is kept).",
49
+ "login.invalidToken": "Login could not be verified. Existing credentials were preserved; check your token and connection.",
46
50
  "logout.done": "✓ Logged out. Reading still works; run `pinglet login` again to post.",
47
51
  // ---- post
48
52
  "reason.EMPTY": "empty messages cannot be posted",
@@ -89,7 +93,7 @@ Internal commands (called by the integration):
89
93
  "doctor.loggedIn": "Logged in",
90
94
  "doctor.notLoggedIn": "Not logged in — reading works; run `pinglet login` to post / react",
91
95
  // ---- misc commands
92
- "uninstall.keep": "○ ~/.pinglet is kept. Use --purge to remove everything.",
96
+ "uninstall.keep": "○ Local cache and history are kept. Use --purge to delete ~/.pinglet too.",
93
97
  "ping.none": "No Ping to show — one will appear once the feed is received.",
94
98
  "postinstall.start": "\npinglet: running first-time setup automatically (`pinglet install`)",
95
99
  "postinstall.failed": "pinglet: automatic setup failed. Please run `pinglet install` yourself.",
@@ -99,7 +103,7 @@ Internal commands (called by the integration):
99
103
  "codex.existingNotify": "An existing notify setting was found (use --force to back it up and replace).",
100
104
  "slash.postDesc": "Post a message to Pinglet so it shows in other developers' terminals",
101
105
  "slash.loginDesc": "Link your Pinglet account (GitHub or Google login in the browser; --github/--google to choose)",
102
- "slash.logoutDesc": "Sign out of Pinglet (removes the login token on this machine only)",
106
+ "slash.logoutDesc": "Sign out of Pinglet (revoke this session and unlink this device)",
103
107
  };
104
108
  const ko = {
105
109
  "cli.help": (p) => `pinglet v${p.version} — AI가 생각하는 동안 만나는 개발자 메시지 네트워크
@@ -108,7 +112,7 @@ const ko = {
108
112
 
109
113
  install [--api <url>] [--force] Claude Code integration 설치
110
114
  [--claude | --codex] Codex는 OS 알림 방식이라 --codex로 opt-in (experimental)
111
- uninstall [--purge] integration 제거 (--purge: ~/.pinglet까지 삭제)
115
+ uninstall [--purge] 설정 복원·서버 인증 해제 (--purge: ~/.pinglet까지 삭제)
112
116
  login [--github | --google] 계정 연결 (GitHub 또는 Google, 생략 시 브라우저에서 선택)
113
117
  [--token <jwt>]
114
118
  logout 로그인 해제 (설치·캐시는 유지)
@@ -138,6 +142,10 @@ const ko = {
138
142
  "login.html.fail": "✗ 로그인 실패",
139
143
  "login.html.failSub": "터미널에서 다시 시도해 주세요.",
140
144
  "logout.notLoggedIn": "○ 로그인 상태가 아닙니다.",
145
+ "logout.failed": "서버 로그아웃에 실패했습니다. 재시도할 수 있도록 인증 정보를 보존했습니다. 연결 후 pinglet logout을 다시 실행하세요.",
146
+ "uninstall.failed": "서버 인증 해제에 실패해 로컬 데이터를 보존했습니다. 연결 후 pinglet uninstall을 다시 실행하세요.",
147
+ "uninstall.npm": "CLI 패키지도 제거하려면: npm uninstall -g pinglet-cli (npm 자체는 유지됩니다).",
148
+ "login.invalidToken": "로그인을 확인할 수 없습니다. 기존 인증 정보는 보존했습니다. 토큰과 서버 연결을 확인하세요.",
141
149
  "logout.done": "✓ 로그아웃했습니다. 읽기는 계속 되고, 메시지 작성은 `pinglet login` 후 가능합니다.",
142
150
  "reason.EMPTY": "빈 메시지는 등록할 수 없습니다",
143
151
  "reason.TOO_LONG": "메시지가 너무 깁니다 (기본 100자 제한)",
@@ -180,7 +188,7 @@ const ko = {
180
188
  "doctor.apiUnreachable": (p) => `API unreachable (${p.api}) — 오프라인 모드로 동작 중`,
181
189
  "doctor.loggedIn": "로그인됨",
182
190
  "doctor.notLoggedIn": "로그인 안 됨 — 읽기는 가능, 메시지 작성/반응은 `pinglet login` 후",
183
- "uninstall.keep": "○ ~/.pinglet은 유지됩니다. 전부 지우려면 --purge를 사용하세요.",
191
+ "uninstall.keep": "○ 로컬 캐시·기록은 유지됩니다. ~/.pinglet도 지우려면 --purge를 사용하세요.",
184
192
  "ping.none": "표시할 Ping이 없습니다 — 서버 feed를 받으면 표시됩니다.",
185
193
  "postinstall.start": "\npinglet: 첫 설정을 자동으로 진행합니다 (`pinglet install`)",
186
194
  "postinstall.failed": "pinglet: 자동 설정에 실패했습니다. `pinglet install`을 직접 실행해 주세요.",
@@ -189,7 +197,7 @@ const ko = {
189
197
  "codex.existingNotify": "기존 notify 설정이 있습니다 (백업 후 교체하려면 --force).",
190
198
  "slash.postDesc": "메시지를 Pinglet에 등록해 다른 개발자들의 터미널에 표시",
191
199
  "slash.loginDesc": "Pinglet 계정 연결 (브라우저에서 GitHub 또는 Google 로그인, --github/--google로 지정 가능)",
192
- "slash.logoutDesc": "Pinglet 로그인 해제 (이 기기의 로그인 토큰만 제거)",
200
+ "slash.logoutDesc": "Pinglet 로그인 해제 (서버 세션 폐기·이 기기의 계정 연결 해제)",
193
201
  };
194
202
  const ja = {
195
203
  "cli.help": (p) => `pinglet v${p.version} — AIが考えている間に出会う開発者メッセージネットワーク
@@ -198,7 +206,7 @@ const ja = {
198
206
 
199
207
  install [--api <url>] [--force] Claude Code integration をインストール
200
208
  [--claude | --codex] Codex は OS 通知方式のため --codex で opt-in (experimental)
201
- uninstall [--purge] integration を削除 (--purge: ~/.pinglet も削除)
209
+ uninstall [--purge] 設定を復元・認証を解除 (--purge: ~/.pinglet も削除)
202
210
  login [--github | --google] アカウント連携 (GitHub または Google、省略時はブラウザで選択)
203
211
  [--token <jwt>]
204
212
  logout ログアウト (連携設定・キャッシュは維持)
@@ -228,6 +236,10 @@ const ja = {
228
236
  "login.html.fail": "✗ ログイン失敗",
229
237
  "login.html.failSub": "ターミナルからもう一度お試しください。",
230
238
  "logout.notLoggedIn": "○ ログインしていません。",
239
+ "logout.failed": "サーバーのログアウトに失敗しました。認証情報は保持しています。接続後に pinglet logout を再実行してください。",
240
+ "uninstall.failed": "サーバーの認証解除に失敗したため、ローカルデータを保持しました。接続後に pinglet uninstall を再実行してください。",
241
+ "uninstall.npm": "CLI パッケージも削除するには: npm uninstall -g pinglet-cli(npm 本体は保持されます)。",
242
+ "login.invalidToken": "ログインを確認できませんでした。既存の認証情報は保持しています。トークンと接続を確認してください。",
231
243
  "logout.done": "✓ ログアウトしました。閲覧は引き続き可能で、投稿は `pinglet login` 後にできます。",
232
244
  "reason.EMPTY": "空のメッセージは投稿できません",
233
245
  "reason.TOO_LONG": "メッセージが長すぎます (デフォルト100文字まで)",
@@ -270,7 +282,7 @@ const ja = {
270
282
  "doctor.apiUnreachable": (p) => `API unreachable (${p.api}) — オフラインモードで動作中`,
271
283
  "doctor.loggedIn": "ログイン済み",
272
284
  "doctor.notLoggedIn": "未ログイン — 閲覧は可能、投稿/リアクションは `pinglet login` 後",
273
- "uninstall.keep": "○ ~/.pinglet は維持されます。すべて削除するには --purge を使ってください。",
285
+ "uninstall.keep": "○ ローカルキャッシュと履歴は保持されます。~/.pinglet も削除するには --purge を使ってください。",
274
286
  "ping.none": "表示する Ping がありません — サーバーから feed を受信すると表示されます。",
275
287
  "postinstall.start": "\npinglet: 初期設定を自動的に行います (`pinglet install`)",
276
288
  "postinstall.failed": "pinglet: 自動設定に失敗しました。`pinglet install` を手動で実行してください。",
@@ -279,7 +291,7 @@ const ja = {
279
291
  "codex.existingNotify": "既存の notify 設定があります (バックアップして置き換えるには --force)。",
280
292
  "slash.postDesc": "メッセージを Pinglet に投稿して他の開発者のターミナルに表示",
281
293
  "slash.loginDesc": "Pinglet アカウント連携 (ブラウザで GitHub または Google ログイン、--github/--google で指定可)",
282
- "slash.logoutDesc": "Pinglet ログアウト (このマシンのログイントークンのみ削除)",
294
+ "slash.logoutDesc": "Pinglet ログアウト (サーバーセッション失効・この端末のアカウント連携解除)",
283
295
  };
284
296
  const tables = { en, ko, ja };
285
297
  function t(key, params = {}) {
package/dist/lock.js ADDED
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.withPingletLock = withPingletLock;
37
+ const fs = __importStar(require("fs"));
38
+ const os = __importStar(require("os"));
39
+ const path = __importStar(require("path"));
40
+ const crypto_1 = require("crypto");
41
+ const storage_1 = require("./storage");
42
+ // Outside ~/.pinglet so purge cannot remove a lock held by another command.
43
+ const LOCK_PATH = path.join(os.homedir(), ".pinglet.lock");
44
+ const RECOVERY_PATH = LOCK_PATH + ".recovery";
45
+ function owner() {
46
+ try {
47
+ return fs.readFileSync(LOCK_PATH, "utf8");
48
+ }
49
+ catch (error) {
50
+ if ((0, storage_1.isMissing)(error))
51
+ return null;
52
+ throw error;
53
+ }
54
+ }
55
+ function abandoned(value) {
56
+ const pid = Number(value.split(":")[0]);
57
+ if (!Number.isInteger(pid) || pid <= 0) {
58
+ // The creating process may not have written its PID yet.
59
+ return Date.now() - fs.statSync(LOCK_PATH).mtimeMs > 30_000;
60
+ }
61
+ try {
62
+ process.kill(pid, 0);
63
+ return false;
64
+ }
65
+ catch (error) {
66
+ return error.code === "ESRCH";
67
+ }
68
+ }
69
+ function recover() {
70
+ // Serialize reapers and re-read ownership: a second reaper must never unlink
71
+ // a fresh lock acquired after the first reaper removed the abandoned one.
72
+ try {
73
+ fs.mkdirSync(RECOVERY_PATH, { mode: 0o700 });
74
+ }
75
+ catch (error) {
76
+ if (error.code === "EEXIST")
77
+ return;
78
+ throw error;
79
+ }
80
+ try {
81
+ const value = owner();
82
+ if (value !== null && abandoned(value))
83
+ fs.unlinkSync(LOCK_PATH);
84
+ }
85
+ catch (error) {
86
+ if (!(0, storage_1.isMissing)(error))
87
+ throw error;
88
+ }
89
+ finally {
90
+ fs.rmdirSync(RECOVERY_PATH);
91
+ }
92
+ }
93
+ /** Serialize the entire read/modify/write command, including asynchronous work.
94
+ * Hooks skip a busy tick instead of blocking Claude/Codex rendering.
95
+ */
96
+ async function withPingletLock(action, options = {}) {
97
+ const identity = `${process.pid}:${(0, crypto_1.randomUUID)()}`;
98
+ const deadline = Date.now() + (options.timeoutMs ?? 15_000);
99
+ for (;;) {
100
+ let fd;
101
+ try {
102
+ fd = fs.openSync(LOCK_PATH, "wx", 0o600);
103
+ }
104
+ catch (error) {
105
+ if (error.code !== "EEXIST")
106
+ throw error;
107
+ recover();
108
+ if (owner() === null)
109
+ continue;
110
+ if (options.skipIfBusy)
111
+ return undefined;
112
+ if (Date.now() >= deadline)
113
+ throw new Error("Pinglet is busy. Retry after the other command finishes.");
114
+ await new Promise((resolve) => setTimeout(resolve, 50));
115
+ continue;
116
+ }
117
+ try {
118
+ fs.writeFileSync(fd, identity);
119
+ }
120
+ catch (error) {
121
+ fs.unlinkSync(LOCK_PATH);
122
+ throw error;
123
+ }
124
+ finally {
125
+ fs.closeSync(fd);
126
+ }
127
+ break;
128
+ }
129
+ try {
130
+ return await action();
131
+ }
132
+ finally {
133
+ if (owner() === identity)
134
+ fs.unlinkSync(LOCK_PATH);
135
+ }
136
+ }
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.pendingPostId = pendingPostId;
37
+ exports.completePost = completePost;
38
+ const crypto_1 = require("crypto");
39
+ const path = __importStar(require("path"));
40
+ const config_1 = require("./config");
41
+ const PENDING_PATH = path.join(config_1.PINGLET_DIR, "pending-posts.json");
42
+ /** Persist before sending so retrying an ambiguous network failure uses the same ID.
43
+ * Only hashes are stored: no message text or bearer token in the retry journal.
44
+ */
45
+ function pendingPostId(config, text, category) {
46
+ const key = (0, crypto_1.createHash)("sha256").update(JSON.stringify([config.apiBaseUrl, config.userToken, text, category ?? null])).digest("hex");
47
+ const pending = (0, config_1.readJsonFile)(PENDING_PATH) ?? [];
48
+ const existing = pending.find((p) => p.key === key);
49
+ if (existing)
50
+ return existing.requestId;
51
+ const requestId = (0, crypto_1.randomUUID)();
52
+ (0, config_1.writeJsonFile)(PENDING_PATH, [...pending, { key, requestId }]);
53
+ return requestId;
54
+ }
55
+ function completePost(requestId) {
56
+ const pending = (0, config_1.readJsonFile)(PENDING_PATH) ?? [];
57
+ (0, config_1.writeJsonFile)(PENDING_PATH, pending.filter((p) => p.requestId !== requestId));
58
+ }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const install_1 = require("./commands/install");
4
4
  const i18n_1 = require("./i18n");
5
+ const lock_1 = require("./lock");
5
6
  /**
6
7
  * npm install -g pinglet-cli 직후 자동으로 `pinglet install`을 수행한다.
7
8
  * - 글로벌 설치(npm_config_global=true)일 때만 동작 — 개발용 npm install,
@@ -14,7 +15,7 @@ async function main() {
14
15
  if (process.env.npm_config_global !== "true")
15
16
  return;
16
17
  console.log((0, i18n_1.t)("postinstall.start"));
17
- await (0, install_1.runInstall)([]);
18
+ await (0, lock_1.withPingletLock)(() => (0, install_1.runInstall)([]));
18
19
  }
19
20
  main()
20
21
  .catch(() => {
package/dist/queue.js CHANGED
@@ -40,8 +40,10 @@ exports.removeEvents = removeEvents;
40
40
  const fs = __importStar(require("fs"));
41
41
  const crypto_1 = require("crypto");
42
42
  const config_1 = require("./config");
43
+ const storage_1 = require("./storage");
43
44
  /**
44
- * Local Event Queue (append-only JSONL).
45
+ * Local Event Queue (JSONL). All CLI writers hold withPingletLock for the
46
+ * complete command, so append and removal cannot interleave across processes.
45
47
  * 노출 즉시 서버를 호출하지 않고 여기 기록했다가 batch 전송한다 (아키텍처 §8).
46
48
  */
47
49
  function appendEvent(input) {
@@ -87,5 +89,5 @@ function countEvents() {
87
89
  function removeEvents(sentEventIds) {
88
90
  const remaining = readEvents().filter((e) => !sentEventIds.has(e.eventId));
89
91
  const body = remaining.map((e) => JSON.stringify(e)).join("\n");
90
- fs.writeFileSync(config_1.EVENTS_PATH, body ? body + "\n" : "");
92
+ (0, storage_1.atomicWrite)(config_1.EVENTS_PATH, body ? body + "\n" : "");
91
93
  }
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isMissing = isMissing;
37
+ exports.atomicWrite = atomicWrite;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const crypto_1 = require("crypto");
41
+ function isMissing(error) {
42
+ return error.code === "ENOENT";
43
+ }
44
+ /** A unique, private temporary file prevents partial reads and writer collisions. */
45
+ function atomicWrite(filePath, body) {
46
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
47
+ const tmp = `${filePath}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`;
48
+ try {
49
+ fs.writeFileSync(tmp, body, { flag: "wx", mode: 0o600 });
50
+ fs.renameSync(tmp, filePath);
51
+ }
52
+ finally {
53
+ try {
54
+ fs.unlinkSync(tmp);
55
+ }
56
+ catch (error) {
57
+ if (!isMissing(error))
58
+ throw error;
59
+ }
60
+ }
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinglet-cli",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "Short messages from other developers shown in your Claude Code spinner while the AI thinks — zero tokens, never reads your code. Claude Code / Codex integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,6 +27,7 @@
27
27
  ],
28
28
  "scripts": {
29
29
  "build": "tsc",
30
+ "test": "npm run build && node --test test/*.test.cjs",
30
31
  "dev": "tsc --watch",
31
32
  "prepare": "npm run build",
32
33
  "postinstall": "node dist/postinstall.js || exit 0"