Skip to content

Commit 321d514

Browse files
feat: add gh-based auth status probe and refresh flow (#3)
1 parent aeb0473 commit 321d514

File tree

8 files changed

+395
-15
lines changed

8 files changed

+395
-15
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ GitHub Copilot SDK 기반으로 Obsidian 사이드바에 Copilot CLI 수준의
1212

1313
- 리포지토리/문서/기본 플러그인 스캐폴드 생성 완료
1414
- M2 기본 UX 반영: 사이드바 채팅 뷰, 세션 생성/전환/삭제, 모의 스트리밍 응답
15-
- 명령 팔레트 연동 확장: `Open`, `Ask about current note`, `Apply pending changes`, `Start new chat session`
15+
- 명령 팔레트 연동 확장: `Open`, `Ask about current note`, `Apply pending changes`, `Start new chat session`, `Undo last applied change`, `Refresh auth status`
1616
- 로컬 세션/상태 저장 및 pending change 적용 기본 흐름 구현
1717
- M3 컨텍스트/변경 흐름 확장: 명시적 추가 컨텍스트 노트 병합, 변경 전 diff 프리뷰, discard/undo 적용
18+
- M1 인증 실연동 경로(Desktop): `gh auth status`/`gh copilot status` 기반 로그인/권한 상태 probe + 수동 재검증
1819
- 상세 요구사항: `docs/requirements.md`
1920
- 아키텍처 초안: `docs/architecture.md`
2021
- 구현 마일스톤: `docs/milestones.md`

docs/atomic-task-plan.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,12 @@
9191
| M3-C02 | 변경 제안 diff 프리뷰 UI 추가 | Copilot CLI | `npm run smoke:run && npm run smoke:assert` |
9292
| M3-C03 | pending discard 및 마지막 적용 undo 구현 | Copilot CLI | `npm run verify:e2e` |
9393
| M3-CL01 | M3 변경의 Cloud 검증 실행 | Cloud Agent | `npm run cloud:dispatch``npm run cloud:status`에서 최신 run 확인 |
94+
95+
## 10) Iteration 5 (M1 Auth Probe) 템플릿
96+
97+
| ID | 목표 | 담당 | 독립 검증 기준 |
98+
|---|---|---|---|
99+
| M1-C01 | gh 기반 로그인 상태 probe 구현 | Copilot CLI | `npm run check && npm run build` |
100+
| M1-C02 | entitlement/no-entitlement 상태 매핑 | Copilot CLI | `npm run smoke:run && npm run smoke:assert` |
101+
| M1-C03 | 수동 재검증 명령/버튼 연계 | Copilot CLI | `npm run verify:e2e` |
102+
| M1-CL01 | M1 변경의 Cloud 검증 실행 | Cloud Agent | `npm run cloud:dispatch``npm run cloud:status`에서 최신 run 확인 |

docs/requirements.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
- GitHub 로그인(디바이스 플로우 또는 SDK 권장 플로우)
3636
- Copilot entitlement 확인
3737
- 로그인 상태 표시: `로그인됨`, `권한 없음`, `토큰 만료`, `오프라인`
38+
- Desktop 환경에서 `gh auth status`/`gh copilot status` 기반 상태 probe를 지원한다.
39+
- 사용자가 사이드바/명령 팔레트에서 인증 상태를 수동 재검증할 수 있어야 한다.
3840

3941
### FR-003 컨텍스트 주입
4042

@@ -54,6 +56,8 @@
5456
- `Ask about current note`
5557
- `Apply pending changes`
5658
- `Start new chat session`
59+
- `Undo last applied change`
60+
- `Refresh auth status`
5761

5862
### FR-006 세션/설정 저장
5963

main.js

Lines changed: 152 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ function defaultSettings() {
5353
additionalContextPaths: [],
5454
lastAppliedChange: null,
5555
authState: "logged-in",
56+
authDetail: "Auth not checked yet.",
57+
authCheckedAt: Date.now(),
5658
model: "gpt-5.3-codex",
5759
contextPolicy: "selection-first",
5860
debugLogging: false
@@ -119,6 +121,15 @@ function buildPreviewDiff(before, after) {
119121
}
120122
return out.join("\n");
121123
}
124+
function compactOutput(stdout, stderr) {
125+
const joined = `${stdout}
126+
${stderr}`.replace(/\s+/g, " ").trim();
127+
return joined.length > 0 ? joined : "(no output)";
128+
}
129+
function containsAny(source, patterns) {
130+
const lowered = source.toLowerCase();
131+
return patterns.some((pattern) => lowered.includes(pattern));
132+
}
122133
function normalizeSettings(raw) {
123134
const fallback = defaultSettings();
124135
if (!raw || typeof raw !== "object") {
@@ -160,6 +171,8 @@ function normalizeSettings(raw) {
160171
additionalContextPaths,
161172
lastAppliedChange,
162173
authState: isAuthState(source.authState) ? source.authState : fallback.authState,
174+
authDetail: typeof source.authDetail === "string" && source.authDetail.trim().length > 0 ? source.authDetail : fallback.authDetail,
175+
authCheckedAt: typeof source.authCheckedAt === "number" ? source.authCheckedAt : fallback.authCheckedAt,
163176
model: typeof source.model === "string" && source.model.trim().length > 0 ? source.model : fallback.model,
164177
contextPolicy: isContextPolicy(source.contextPolicy) ? source.contextPolicy : fallback.contextPolicy,
165178
debugLogging: Boolean(source.debugLogging)
@@ -183,7 +196,9 @@ var CopilotSidebarView = class extends import_obsidian.ItemView {
183196
const root = container.createDiv({ cls: "copilot-sidebar-root" });
184197
const header = root.createDiv({ cls: "copilot-sidebar-header" });
185198
const title = header.createDiv({ text: "Copilot Sidebar", cls: "copilot-sidebar-title" });
186-
const authBadge = header.createDiv({ cls: "copilot-auth-badge" });
199+
const authMeta = header.createDiv({ cls: "copilot-auth-meta" });
200+
const authBadge = authMeta.createDiv({ cls: "copilot-auth-badge" });
201+
const authDetail = authMeta.createDiv({ cls: "copilot-auth-detail" });
187202
const controlRow = root.createDiv({ cls: "copilot-sidebar-controls" });
188203
const newSessionButton = controlRow.createEl("button", {
189204
text: "New Session",
@@ -201,8 +216,8 @@ var CopilotSidebarView = class extends import_obsidian.ItemView {
201216
text: "Add Active Context",
202217
cls: "copilot-button"
203218
});
204-
const authCycleButton = controlRow.createEl("button", {
205-
text: "Cycle Auth",
219+
const refreshAuthButton = controlRow.createEl("button", {
220+
text: "Refresh Auth",
206221
cls: "copilot-button"
207222
});
208223
const layout = root.createDiv({ cls: "copilot-sidebar-layout" });
@@ -237,8 +252,8 @@ var CopilotSidebarView = class extends import_obsidian.ItemView {
237252
addContextButton.addEventListener("click", () => {
238253
void this.plugin.addActiveNoteToContext();
239254
});
240-
authCycleButton.addEventListener("click", () => {
241-
void this.plugin.cycleAuthState();
255+
refreshAuthButton.addEventListener("click", () => {
256+
void this.plugin.refreshAuthStatus("manual");
242257
});
243258
composerButton.addEventListener("click", () => {
244259
void this.submitComposer();
@@ -252,6 +267,7 @@ var CopilotSidebarView = class extends import_obsidian.ItemView {
252267
this.elements = {
253268
title,
254269
authBadge,
270+
authMeta: authDetail,
255271
sessionList,
256272
contextList,
257273
pendingList,
@@ -275,14 +291,17 @@ var CopilotSidebarView = class extends import_obsidian.ItemView {
275291
this.elements.title.setText(activeSession ? activeSession.title : "Copilot Sidebar");
276292
this.elements.authBadge.setText(`Auth: ${snapshot.authState}`);
277293
this.elements.authBadge.className = `copilot-auth-badge auth-${snapshot.authState}`;
294+
const checkedAt = new Date(snapshot.authCheckedAt).toLocaleTimeString();
295+
const compactDetail = snapshot.authDetail.length > 180 ? `${snapshot.authDetail.slice(0, 177)}...` : snapshot.authDetail;
296+
this.elements.authMeta.setText(`${compactDetail} | checked ${checkedAt}`);
278297
this.renderSessions(snapshot);
279298
this.renderContextNotes(snapshot);
280299
this.renderPendingChanges(snapshot);
281300
this.renderMessages(activeSession);
282301
this.renderPreview(snapshot);
283302
this.elements.composerButton.disabled = snapshot.isStreaming;
284303
this.elements.composerInput.disabled = snapshot.isStreaming;
285-
this.elements.composerInput.placeholder = snapshot.authState === "logged-in" ? "Ask Copilot about this vault..." : "Auth state is not logged-in. Use Cycle Auth (mock) to simulate recovery.";
304+
this.elements.composerInput.placeholder = snapshot.authState === "logged-in" ? "Ask Copilot about this vault..." : "Auth state is not logged-in. Use Refresh Auth to re-check login and entitlement.";
286305
}
287306
renderSessions(snapshot) {
288307
if (!this.elements) {
@@ -496,6 +515,14 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
496515
await this.undoLastAppliedChange();
497516
}
498517
});
518+
this.addCommand({
519+
id: "refresh-auth-status",
520+
name: "Refresh auth status",
521+
callback: async () => {
522+
await this.refreshAuthStatus("manual");
523+
}
524+
});
525+
void this.refreshAuthStatus("startup");
499526
}
500527
async onunload() {
501528
for (const timer of this.activeStreamTimers) {
@@ -523,6 +550,8 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
523550
additionalContextPaths: [...this.settings.additionalContextPaths],
524551
lastAppliedChange: this.settings.lastAppliedChange ? { ...this.settings.lastAppliedChange } : null,
525552
authState: this.settings.authState,
553+
authDetail: this.settings.authDetail,
554+
authCheckedAt: this.settings.authCheckedAt,
526555
model: this.settings.model,
527556
isStreaming: this.streaming
528557
};
@@ -559,8 +588,125 @@ var CopilotSidebarPlugin = class extends import_obsidian.Plugin {
559588
const currentIndex = AUTH_ORDER.indexOf(this.settings.authState);
560589
const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % AUTH_ORDER.length : 0;
561590
this.settings.authState = AUTH_ORDER[nextIndex];
591+
this.settings.authDetail = "Manual auth state override.";
592+
this.settings.authCheckedAt = Date.now();
562593
await this.persistAndRender();
563594
}
595+
async refreshAuthStatus(trigger = "manual") {
596+
const probe = this.probeAuthStatusFromGh();
597+
this.settings.authState = probe.state;
598+
this.settings.authDetail = probe.detail;
599+
this.settings.authCheckedAt = probe.checkedAt;
600+
await this.persistAndRender();
601+
if (trigger === "manual") {
602+
new import_obsidian.Notice(`Auth check: ${probe.state}`);
603+
}
604+
}
605+
probeAuthStatusFromGh() {
606+
const checkedAt = Date.now();
607+
const ghVersion = this.runLocalCommand("gh", ["--version"], 2500);
608+
if (ghVersion.status !== 0) {
609+
return {
610+
state: "offline",
611+
detail: "gh CLI not found. Install/authenticate GitHub CLI.",
612+
checkedAt
613+
};
614+
}
615+
const authStatus = this.runLocalCommand("gh", ["auth", "status", "-h", "github.com"], 6e3);
616+
const authOutput = compactOutput(authStatus.stdout, authStatus.stderr);
617+
if (authStatus.status !== 0) {
618+
if (containsAny(authOutput, ["expired", "token", "not logged", "authentication"])) {
619+
return {
620+
state: "token-expired",
621+
detail: `GitHub auth issue: ${authOutput}`,
622+
checkedAt
623+
};
624+
}
625+
return {
626+
state: "offline",
627+
detail: `Unable to reach GitHub auth status: ${authOutput}`,
628+
checkedAt
629+
};
630+
}
631+
const copilotStatus = this.runLocalCommand("gh", ["copilot", "status"], 6e3);
632+
const copilotOutput = compactOutput(copilotStatus.stdout, copilotStatus.stderr);
633+
if (copilotStatus.status === 0) {
634+
return {
635+
state: "logged-in",
636+
detail: `GitHub login and Copilot status confirmed. ${copilotOutput}`,
637+
checkedAt
638+
};
639+
}
640+
if (containsAny(copilotOutput, ["not entitled", "not enabled", "no entitlement", "not subscribed"])) {
641+
return {
642+
state: "no-entitlement",
643+
detail: `GitHub login ok but Copilot entitlement missing. ${copilotOutput}`,
644+
checkedAt
645+
};
646+
}
647+
if (containsAny(copilotOutput, ["token", "authentication", "not logged", "login"])) {
648+
return {
649+
state: "token-expired",
650+
detail: `Copilot auth needs refresh. ${copilotOutput}`,
651+
checkedAt
652+
};
653+
}
654+
if (containsAny(copilotOutput, ["unknown command", "usage:"])) {
655+
return {
656+
state: "logged-in",
657+
detail: "GitHub login detected. Copilot status command unavailable in this gh build.",
658+
checkedAt
659+
};
660+
}
661+
return {
662+
state: "logged-in",
663+
detail: `GitHub login detected. Copilot status inconclusive: ${copilotOutput}`,
664+
checkedAt
665+
};
666+
}
667+
runLocalCommand(command, args, timeoutMs) {
668+
const dynamicRequire = this.getDynamicRequire();
669+
if (!dynamicRequire) {
670+
return {
671+
status: -1,
672+
stdout: "",
673+
stderr: "dynamic require is unavailable in this runtime",
674+
error: "require-unavailable"
675+
};
676+
}
677+
try {
678+
const childProcess = dynamicRequire("node:child_process");
679+
const result = childProcess.spawnSync(command, args, {
680+
encoding: "utf8",
681+
timeout: timeoutMs
682+
});
683+
const error = result.error instanceof Error ? result.error.message : null;
684+
return {
685+
status: result.status ?? -1,
686+
stdout: typeof result.stdout === "string" ? result.stdout : "",
687+
stderr: typeof result.stderr === "string" ? result.stderr : "",
688+
error
689+
};
690+
} catch (error) {
691+
return {
692+
status: -1,
693+
stdout: "",
694+
stderr: "",
695+
error: error instanceof Error ? error.message : String(error)
696+
};
697+
}
698+
}
699+
getDynamicRequire() {
700+
const fromGlobal = globalThis.require;
701+
if (typeof fromGlobal === "function") {
702+
return fromGlobal;
703+
}
704+
const fromWindow = globalThis.window?.require;
705+
if (typeof fromWindow === "function") {
706+
return fromWindow;
707+
}
708+
return null;
709+
}
564710
async addActiveNoteToContext() {
565711
const activeFile = this.app.workspace.getActiveFile();
566712
if (!(activeFile instanceof import_obsidian.TFile)) {

scripts/smoke-assert.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const requiredCommands = [
2121
"apply-pending-changes",
2222
"ask-about-current-note",
2323
"open-copilot-sidebar",
24+
"refresh-auth-status",
2425
"start-new-chat-session",
2526
"undo-last-applied-change"
2627
];

scripts/smoke-run.mjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,9 @@ try {
215215
"apply-pending-changes",
216216
"ask-about-current-note",
217217
"open-copilot-sidebar",
218-
"start-new-chat-session"
218+
"refresh-auth-status",
219+
"start-new-chat-session",
220+
"undo-last-applied-change"
219221
];
220222
for (const commandId of requiredCommands) {
221223
assert.ok(commandIds.includes(commandId), `missing required command: ${commandId}`);
@@ -235,16 +237,19 @@ try {
235237
const openCommand = plugin.__commands.find((command) => command.id === "open-copilot-sidebar");
236238
const startSessionCommand = plugin.__commands.find((command) => command.id === "start-new-chat-session");
237239
const applyCommand = plugin.__commands.find((command) => command.id === "apply-pending-changes");
240+
const refreshAuthCommand = plugin.__commands.find((command) => command.id === "refresh-auth-status");
238241
const undoCommand = plugin.__commands.find((command) => command.id === "undo-last-applied-change");
239242

240243
assert.ok(openCommand, "open command should exist");
241244
assert.ok(startSessionCommand, "start session command should exist");
242245
assert.ok(applyCommand, "apply command should exist");
246+
assert.ok(refreshAuthCommand, "refresh auth command should exist");
243247
assert.ok(undoCommand, "undo command should exist");
244248

245249
await openCommand.callback();
246250
await startSessionCommand.callback();
247251
await applyCommand.callback();
252+
await refreshAuthCommand.callback();
248253
await undoCommand.callback();
249254

250255
await view.onClose();

0 commit comments

Comments
 (0)