pinglet-cli 0.5.4 → 0.6.1

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
@@ -38,8 +38,11 @@ exports.checkHealth = checkHealth;
38
38
  exports.registerInstallation = registerInstallation;
39
39
  exports.fetchFeed = fetchFeed;
40
40
  exports.uploadEventBatch = uploadEventBatch;
41
+ exports.uploadEventBatchResult = uploadEventBatchResult;
41
42
  exports.sendHeartbeat = sendHeartbeat;
42
43
  exports.createMessage = createMessage;
44
+ exports.validateUserToken = validateUserToken;
45
+ exports.releaseCredential = releaseCredential;
43
46
  exports.linkInstallation = linkInstallation;
44
47
  const os = __importStar(require("os"));
45
48
  const crypto_1 = require("crypto");
@@ -100,6 +103,7 @@ async function fetchFeed(config, record, limit = 50) {
100
103
  text: item.text,
101
104
  author: item.authorNickname || "익명의 개발자",
102
105
  category: item.category,
106
+ expiresAt: item.expiresAt,
103
107
  contentType: item.contentType === "SYSTEM" || item.contentType === "SPONSORED"
104
108
  ? item.contentType
105
109
  : "USER",
@@ -107,20 +111,30 @@ async function fetchFeed(config, record, limit = 50) {
107
111
  }
108
112
  /** POST /events/batch — eventId 기반으로 서버가 dedupe하므로 재전송에 안전하다. */
109
113
  async function uploadEventBatch(config, record, events) {
110
- const res = await request(config, "POST", "/events/batch", {
111
- token: record.token,
112
- body: {
113
- batchId: "batch_" + (0, crypto_1.randomUUID)(),
114
- events: events.map(({ eventId, type, messageId, visibleMs, occurredAt }) => ({
115
- eventId,
116
- type,
117
- messageId,
118
- ...(visibleMs !== undefined && { visibleMs }),
119
- occurredAt,
120
- })),
121
- },
122
- });
123
- return res !== null;
114
+ return (await uploadEventBatchResult(config, record, events)).ok;
115
+ }
116
+ async function uploadEventBatchResult(config, record, events) {
117
+ try {
118
+ const res = await fetch(config.apiBaseUrl + "/events/batch", {
119
+ method: "POST",
120
+ headers: { "content-type": "application/json", authorization: `Bearer ${record.token}` },
121
+ signal: AbortSignal.timeout(TIMEOUT_MS),
122
+ body: JSON.stringify({
123
+ batchId: "batch_" + (0, crypto_1.randomUUID)(),
124
+ events: events.map(({ eventId, type, messageId, visibleMs, occurredAt }) => ({
125
+ eventId,
126
+ type,
127
+ messageId,
128
+ ...(visibleMs !== undefined && { visibleMs }),
129
+ occurredAt,
130
+ })),
131
+ }),
132
+ });
133
+ return { ok: res.ok, permanent: [400, 413, 422].includes(res.status), status: res.status };
134
+ }
135
+ catch {
136
+ return { ok: false, permanent: false };
137
+ }
124
138
  }
125
139
  /** POST /installations/heartbeat — lastSeenAt 갱신. 응답의 온라인 설치 수를 돌려준다(구서버는 null). */
126
140
  async function sendHeartbeat(config, record) {
@@ -136,7 +150,7 @@ async function sendHeartbeat(config, record) {
136
150
  * POST /messages — 메시지 작성 (분당 5개 제한).
137
151
  * 작성은 로그인(GitHub 또는 Google)이 필요하다 (읽기는 계속 익명).
138
152
  */
139
- async function createMessage(config, text, category) {
153
+ async function createMessage(config, text, category, requestId = (0, crypto_1.randomUUID)()) {
140
154
  const token = config.userToken;
141
155
  if (!token)
142
156
  return { ok: false, error: "LOGIN_REQUIRED" };
@@ -147,7 +161,7 @@ async function createMessage(config, text, category) {
147
161
  "content-type": "application/json",
148
162
  authorization: `Bearer ${token}`,
149
163
  },
150
- body: JSON.stringify({ text, ...(category && { category }) }),
164
+ body: JSON.stringify({ text, requestId, ...(category && { category }) }),
151
165
  signal: AbortSignal.timeout(TIMEOUT_MS),
152
166
  });
153
167
  if (res.status === 401)
@@ -168,6 +182,32 @@ async function createMessage(config, text, category) {
168
182
  return { ok: false, error: "NETWORK" };
169
183
  }
170
184
  }
185
+ async function validateUserToken(config, token) {
186
+ const user = await request(config, "GET", "/users/me", { token });
187
+ return typeof user?.id === "string" && user.id.length > 0;
188
+ }
189
+ /** Revocation/unlink are idempotent. A 401 installation credential is already unusable.
190
+ * Other failures (including an old server's 404) must not be reported as success.
191
+ */
192
+ async function releaseCredential(config, kind, token) {
193
+ try {
194
+ const apiPath = kind === "user" ? "/auth/logout" : kind === "unlink" ? "/installations/unlink" : "/installations/current";
195
+ const res = await fetch(config.apiBaseUrl + apiPath, {
196
+ method: kind === "installation" ? "DELETE" : "POST",
197
+ headers: { authorization: `Bearer ${token}` },
198
+ signal: AbortSignal.timeout(TIMEOUT_MS),
199
+ });
200
+ if (res.status === 401)
201
+ return true;
202
+ if (!res.ok)
203
+ return false;
204
+ const body = await res.json();
205
+ return kind === "unlink" ? body.unlinked === true : body.revoked === true;
206
+ }
207
+ catch {
208
+ return false;
209
+ }
210
+ }
171
211
  /**
172
212
  * POST /installations/link — 로그인 후 기존 익명 설치를 계정에 연결한다.
173
213
  * 소유 증명으로 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 = {
@@ -35,12 +43,17 @@ function feedAgeMs() {
35
43
  return Number.isNaN(t) ? null : Date.now() - t;
36
44
  }
37
45
  function loadState() {
38
- const state = (0, config_1.readJsonFile)(config_1.STATE_PATH);
39
- if (state) {
40
- state.seen ??= {};
41
- return state;
46
+ const state = (0, config_1.readJsonFile)(config_1.STATE_PATH) ?? { seen: {} };
47
+ const installationId = (0, config_1.readJsonFile)(config_1.CONFIG_PATH)?.installations?.CLAUDE?.installationId;
48
+ if (state.claudeInstallationId && state.claudeInstallationId !== installationId) {
49
+ state.seen = {};
50
+ state.delivered = {};
51
+ state.current = undefined;
52
+ state.sessions = undefined;
42
53
  }
43
- return { seen: {} };
54
+ state.claudeInstallationId = installationId;
55
+ state.seen ??= {};
56
+ return state;
44
57
  }
45
58
  function loadMyPosts() {
46
59
  return (0, config_1.readJsonFile)(config_1.POSTS_PATH) ?? [];
package/dist/cli.js CHANGED
@@ -8,12 +8,14 @@ const doctor_1 = require("./commands/doctor");
8
8
  const login_1 = require("./commands/login");
9
9
  const logout_1 = require("./commands/logout");
10
10
  const statusline_1 = require("./commands/statusline");
11
- const notify_1 = require("./commands/notify");
12
11
  const flush_1 = require("./commands/flush");
13
12
  const refresh_1 = require("./commands/refresh");
14
13
  const ping_1 = require("./commands/ping");
15
14
  const post_1 = require("./commands/post");
16
15
  const i18n_1 = require("./i18n");
16
+ const lock_1 = require("./lock");
17
+ const notifications_1 = require("./notifications");
18
+ const runtime_1 = require("./runtime");
17
19
  const HELP = (0, i18n_1.t)("cli.help", { version: config_1.VERSION });
18
20
  async function main() {
19
21
  const [command, ...rest] = process.argv.slice(2);
@@ -22,13 +24,13 @@ async function main() {
22
24
  await (0, install_1.runInstall)(rest);
23
25
  break;
24
26
  case "uninstall":
25
- (0, uninstall_1.runUninstall)(rest);
27
+ await (0, uninstall_1.runUninstall)(rest);
26
28
  break;
27
29
  case "login":
28
30
  await (0, login_1.runLogin)(rest);
29
31
  break;
30
32
  case "logout":
31
- (0, logout_1.runLogout)();
33
+ await (0, logout_1.runLogout)();
32
34
  break;
33
35
  case "doctor":
34
36
  await (0, doctor_1.runDoctor)();
@@ -42,9 +44,6 @@ async function main() {
42
44
  case "statusline":
43
45
  (0, statusline_1.runStatusline)();
44
46
  break;
45
- case "notify":
46
- (0, notify_1.runNotify)(rest);
47
- break;
48
47
  case "flush":
49
48
  await (0, flush_1.runFlush)();
50
49
  break;
@@ -72,10 +71,32 @@ async function main() {
72
71
  }
73
72
  }
74
73
  }
75
- main().catch((error) => {
74
+ async function dispatch() {
75
+ const command = process.argv[2];
76
+ if (command === "notify") {
77
+ if ((0, notifications_1.enqueueNotification)(process.argv.slice(3)))
78
+ (0, runtime_1.spawnDetachedCommand)(["notify-drain"]);
79
+ return;
80
+ }
81
+ if (command === "notify-drain" || command === "statusline") {
82
+ await (0, lock_1.withPingletLock)(() => {
83
+ (0, notifications_1.drainNotifications)();
84
+ if (command === "statusline")
85
+ (0, statusline_1.runStatusline)();
86
+ }, { skipIfBusy: command === "statusline" });
87
+ return;
88
+ }
89
+ if (["--version", "-v", "help", "--help"].includes(command)) {
90
+ await main();
91
+ return;
92
+ }
93
+ await (0, lock_1.withCommandLock)(main);
94
+ await (0, lock_1.withPingletLock)(notifications_1.drainNotifications);
95
+ }
96
+ dispatch().catch((error) => {
76
97
  // statusline/notify는 조용히 실패해야 Claude/Codex 경험을 해치지 않는다.
77
98
  const command = process.argv[2];
78
- if (command === "statusline" || command === "notify") {
99
+ if (command === "statusline" || command === "notify" || command === "notify-drain") {
79
100
  process.exit(0);
80
101
  }
81
102
  console.error("pinglet:", error instanceof Error ? error.message : error);
@@ -1,19 +1,86 @@
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");
41
+ const lock_1 = require("../lock");
7
42
  /**
8
43
  * Local Event Queue를 batch로 서버에 전송한다 (아키텍처 §8).
9
44
  * 전송 성공한 이벤트만 큐에서 제거하므로 실패분은 다음 주기에 재시도되고,
10
45
  * 재전송 중복은 서버가 eventId로 dedupe한다.
11
46
  */
12
47
  async function runFlush() {
13
- const config = (0, config_1.loadConfig)();
14
- const events = (0, queue_1.readEvents)();
15
- if (events.length === 0)
48
+ const snapshot = await (0, lock_1.withPingletLock)(() => {
49
+ if (!fs.existsSync(config_1.CONFIG_PATH))
50
+ return;
51
+ const config = (0, config_1.loadConfig)();
52
+ const events = (0, queue_1.bindEvents)();
53
+ const valid = [];
54
+ for (const event of events) {
55
+ const reason = (0, queue_1.invalidEventReason)(event);
56
+ const record = config.installations[event.agentType];
57
+ if (reason)
58
+ (0, queue_1.quarantineEvents)([event], reason);
59
+ else if (event.installationId && event.installationId !== record?.installationId)
60
+ (0, queue_1.quarantineEvents)([event], "retired-installation");
61
+ else
62
+ valid.push(event);
63
+ }
64
+ return { config, events: valid };
65
+ });
66
+ if (!snapshot)
16
67
  return;
68
+ const { config, events } = snapshot;
69
+ const send = async (record, chunk) => {
70
+ const result = await (0, api_1.uploadEventBatchResult)(config, record, chunk);
71
+ if (result.ok) {
72
+ await (0, lock_1.withPingletLock)(() => (0, queue_1.removeEvents)(new Set(chunk.map(event => event.eventId))));
73
+ return true;
74
+ }
75
+ if (!result.permanent)
76
+ return false;
77
+ if (chunk.length === 1) {
78
+ await (0, lock_1.withPingletLock)(() => (0, queue_1.quarantineEvents)(chunk, `http-${result.status}`));
79
+ return true;
80
+ }
81
+ const middle = Math.floor(chunk.length / 2);
82
+ return await send(record, chunk.slice(0, middle)) && await send(record, chunk.slice(middle));
83
+ };
17
84
  for (const agentType of ["CLAUDE", "CODEX"]) {
18
85
  const record = config.installations[agentType];
19
86
  const group = events.filter((e) => e.agentType === agentType);
@@ -23,10 +90,8 @@ async function runFlush() {
23
90
  continue; // 미등록 상태 — refresh가 등록을 재시도한다.
24
91
  for (let i = 0; i < group.length; i += api_1.MAX_BATCH_SIZE) {
25
92
  const chunk = group.slice(i, i + api_1.MAX_BATCH_SIZE);
26
- const ok = await (0, api_1.uploadEventBatch)(config, record, chunk);
27
- if (!ok)
93
+ if (!await send(record, chunk))
28
94
  break;
29
- (0, queue_1.removeEvents)(new Set(chunk.map((e) => e.eventId)));
30
95
  }
31
96
  }
32
97
  }
@@ -41,6 +41,7 @@ const cache_1 = require("../cache");
41
41
  const claude_1 = require("../adapters/claude");
42
42
  const codex_1 = require("../adapters/codex");
43
43
  const i18n_1 = require("../i18n");
44
+ const lock_1 = require("../lock");
44
45
  /**
45
46
  * 기존 설정 교체 확인. Enter 또는 y면 진행(기본 Yes — 설치 의도가 명확하므로).
46
47
  * non-TTY(postinstall, CI)에서는 묻지 않고 false — 사용자 설정을 말없이
@@ -72,7 +73,11 @@ async function runInstall(args) {
72
73
  const apiIndex = args.indexOf("--api");
73
74
  if (apiIndex !== -1 && args[apiIndex + 1]) {
74
75
  config.apiBaseUrl = args[apiIndex + 1].replace(/\/$/, "");
75
- (0, config_1.saveConfig)(config);
76
+ await (0, lock_1.withPingletLock)(() => {
77
+ const current = (0, config_1.loadConfig)();
78
+ current.apiBaseUrl = config.apiBaseUrl;
79
+ (0, config_1.saveConfig)(current);
80
+ });
76
81
  }
77
82
  console.log("");
78
83
  const hasClaude = wantClaude && (0, claude_1.detectClaude)();
@@ -99,17 +104,21 @@ async function runInstall(args) {
99
104
  return;
100
105
  const record = await (0, api_1.registerInstallation)(config, agentType);
101
106
  if (record) {
107
+ await (0, lock_1.withPingletLock)(() => {
108
+ const current = (0, config_1.loadConfig)();
109
+ current.installations[agentType] = record;
110
+ (0, config_1.saveConfig)(current);
111
+ });
102
112
  config.installations[agentType] = record;
103
- (0, config_1.saveConfig)(config);
104
113
  }
105
114
  };
106
115
  if (hasClaude) {
107
116
  await register("CLAUDE");
108
- let result = (0, claude_1.installClaudeIntegration)(config, { force });
117
+ let result = (await (0, lock_1.withPingletLock)(() => (0, claude_1.installClaudeIntegration)((0, config_1.loadConfig)(), { force })));
109
118
  if (!result.ok && result.needsForce) {
110
119
  const yes = await confirmReplace((0, i18n_1.t)("install.askReplaceStatusLine"));
111
120
  if (yes)
112
- result = (0, claude_1.installClaudeIntegration)(config, { force: true });
121
+ result = (await (0, lock_1.withPingletLock)(() => (0, claude_1.installClaudeIntegration)((0, config_1.loadConfig)(), { force: true })));
113
122
  }
114
123
  console.log(result.ok
115
124
  ? "✓ Claude integration installed"
@@ -117,11 +126,11 @@ async function runInstall(args) {
117
126
  }
118
127
  if (hasCodex) {
119
128
  await register("CODEX");
120
- let result = (0, codex_1.installCodexIntegration)(config, { force });
129
+ let result = (await (0, lock_1.withPingletLock)(() => (0, codex_1.installCodexIntegration)((0, config_1.loadConfig)(), { force })));
121
130
  if (!result.ok && result.needsForce) {
122
131
  const yes = await confirmReplace((0, i18n_1.t)("install.askReplaceNotify"));
123
132
  if (yes)
124
- result = (0, codex_1.installCodexIntegration)(config, { force: true });
133
+ result = (await (0, lock_1.withPingletLock)(() => (0, codex_1.installCodexIntegration)((0, config_1.loadConfig)(), { force: true })));
125
134
  }
126
135
  console.log(result.ok
127
136
  ? "✓ Codex integration installed (experimental)"
@@ -132,10 +141,10 @@ async function runInstall(args) {
132
141
  if (online && record) {
133
142
  const messages = await (0, api_1.fetchFeed)(config, record);
134
143
  if (messages) {
135
- (0, cache_1.saveFeedMessages)(messages);
144
+ await (0, lock_1.withPingletLock)(() => (0, cache_1.saveFeedMessages)(messages));
136
145
  if (messages.length > 0) {
137
146
  console.log(`✓ Feed cached (${messages.length} messages)`);
138
- if ((0, claude_1.armSpinnerMessage)(config, messages[0] ?? null)) {
147
+ if (await (0, lock_1.withPingletLock)(() => (0, claude_1.armSpinnerMessage)((0, config_1.loadConfig)(), messages[0] ?? null))) {
139
148
  console.log("✓ Claude spinner synced");
140
149
  }
141
150
  }