@aexol/opencode-wizard 0.1.11 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,7 +45,7 @@ Use `skills.urls` only for public registries that are intentionally cacheable by
45
45
 
46
46
  ## Catalog discovery and auth bootstrap
47
47
 
48
- On chat/system-context startup, the plugin attempts to load the catalog automatically with the stored plugin session at `plugin/opencode-wizard/.generated/auth-state.json`. If no valid plugin session exists, startup stays passive and reports that interactive fetch will bootstrap browser login when needed; it does not open the browser from system context.
48
+ On chat/system-context startup, the plugin attempts to load the catalog automatically with the stored plugin auth at `~/.config/opencode/opencode-wizard.json` (`auth` field). If no valid plugin session exists, startup stays passive and reports that interactive fetch will bootstrap browser login when needed; it does not open the browser from system context.
49
49
 
50
50
  Call `opencode_wizard_published_skills_fetch` without `skill` or `skills` to manually bootstrap plugin login if needed and return catalog-only discovery output for the current directory scope.
51
51
 
package/dist/server.d.ts CHANGED
@@ -25,7 +25,7 @@ export type PublishedSkillCatalogPayload = {
25
25
  repositoryUrl?: string | null;
26
26
  defaultBranch?: string | null;
27
27
  status: string;
28
- };
28
+ } | null;
29
29
  directoryPath: string;
30
30
  skills: PublishedSkillCatalogItem[];
31
31
  };
@@ -226,6 +226,11 @@ export declare const resolvePluginStatusSnapshot: ({ worktree, directory, signal
226
226
  directory: string;
227
227
  signal: AbortSignal;
228
228
  }) => Promise<PluginStatusSnapshot>;
229
+ export declare const resolvePluginStatusSnapshotWithAuthBootstrap: ({ worktree, directory, signal, }: {
230
+ worktree: string;
231
+ directory: string;
232
+ signal: AbortSignal;
233
+ }) => Promise<PluginStatusSnapshot>;
229
234
  declare const _default: {
230
235
  id: string;
231
236
  server: OpencodePluginServer;
package/dist/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import http from 'node:http';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import crypto from 'node:crypto';
5
6
  import { execFile } from 'node:child_process';
@@ -11,7 +12,8 @@ const PACKAGE_ROOT_PATH = path.resolve(path.dirname(MODULE_FILE_PATH), '..');
11
12
  export const PLUGIN_ID = 'opencode-wizard';
12
13
  const CACHE_TTL_MS = 30_000;
13
14
  const ROOT_SKILL_SEED_PATH = '.opencode/skills';
14
- const AUTH_STATE_PATH = 'plugin/opencode-wizard/.generated/auth-state.json';
15
+ const GLOBAL_CONFIG_PATH = path.join(os.homedir(), '.config', 'opencode', 'opencode-wizard.json');
16
+ const LEGACY_AUTH_STATE_PATH = 'plugin/opencode-wizard/.generated/auth-state.json';
15
17
  const PUBLISHED_BACKEND_ORIGIN = 'https://opencode-wizard.aexol.work';
16
18
  const OIDC_ISSUER = 'https://login.microsoftonline.com/86f4caf4-0d6f-4682-9a06-ea57f3e4e76c/v2.0';
17
19
  const OIDC_CLIENT_ID = 'da963901-2375-442b-9e99-14e59f43eda2';
@@ -43,6 +45,13 @@ const createIdleLoginBootstrapSnapshot = () => ({
43
45
  email: null,
44
46
  message: null
45
47
  });
48
+ const STATUS_PATH_LOGIN_RETRY_COOLDOWN_MS = 60_000;
49
+ const statusPathLoginBootstrap = {
50
+ promise: null,
51
+ status: 'idle',
52
+ message: null,
53
+ failedAt: null
54
+ };
46
55
  const importOpencodePluginModule = new Function('specifier', 'return import(specifier)');
47
56
  export const AVAILABLE_PUBLISHED_SKILL_TOOLS = ['opencode_wizard_published_skills_fetch', 'opencode_wizard_status'];
48
57
  export const NATIVE_SKILLS_URL_COMPATIBILITY = {
@@ -201,7 +210,7 @@ export const resolveConfig = async worktree => {
201
210
  actionsUrl: `${backendOrigin}/api/opencode-plugin/actions`,
202
211
  fallbackWorkspaceSlug: resolveFallbackWorkspaceSlug(worktree),
203
212
  rootSkillSeedPath: ROOT_SKILL_SEED_PATH,
204
- authStatePath: AUTH_STATE_PATH
213
+ authStatePath: GLOBAL_CONFIG_PATH
205
214
  };
206
215
  };
207
216
  const normalizeAbsolutePath = value => path.resolve(value);
@@ -325,15 +334,45 @@ const isAuthState = value => {
325
334
  if (!isRecord(value)) return false;
326
335
  return value.pluginId === PLUGIN_ID && typeof value.sessionToken === 'string' && isValidIsoDateString(value.expiresAt) && isValidIsoDateString(value.authenticatedAt) && typeof value.userId === 'string' && typeof value.email === 'string';
327
336
  };
328
- const readAuthState = async authStateFile => {
337
+ const readGlobalConfig = async configFile => {
338
+ const storedConfig = await readJsonFile(configFile);
339
+ if (isRecord(storedConfig)) return storedConfig;
340
+ return {};
341
+ };
342
+ const writeGlobalConfig = async (configFile, config) => {
343
+ await writeJsonFile(configFile, config);
344
+ };
345
+ const readGlobalAuthState = async configFile => {
346
+ const storedConfig = await readGlobalConfig(configFile);
347
+ const storedAuthState = storedConfig.auth;
348
+ if (storedAuthState === undefined || storedAuthState === null) return null;
349
+ if (isAuthState(storedAuthState)) return storedAuthState;
350
+ await writeGlobalConfig(configFile, {
351
+ ...storedConfig,
352
+ auth: null
353
+ });
354
+ return null;
355
+ };
356
+ const readLegacyAuthState = async authStateFile => {
329
357
  const storedAuthState = await readJsonFile(authStateFile);
330
358
  if (storedAuthState === null) return null;
331
359
  if (isAuthState(storedAuthState)) return storedAuthState;
332
360
  await deleteFileIfExists(authStateFile);
333
361
  return null;
334
362
  };
335
- const writeAuthState = async (authStateFile, authState) => {
336
- await writeJsonFile(authStateFile, authState);
363
+ const writeAuthState = async (configFile, authState) => {
364
+ const storedConfig = await readGlobalConfig(configFile);
365
+ await writeGlobalConfig(configFile, {
366
+ ...storedConfig,
367
+ auth: authState
368
+ });
369
+ };
370
+ const clearAuthState = async configFile => {
371
+ const storedConfig = await readGlobalConfig(configFile);
372
+ await writeGlobalConfig(configFile, {
373
+ ...storedConfig,
374
+ auth: null
375
+ });
337
376
  };
338
377
  const toAuthState = session => ({
339
378
  pluginId: PLUGIN_ID,
@@ -344,16 +383,24 @@ const toAuthState = session => ({
344
383
  email: session.user.email
345
384
  });
346
385
  const resolveStoredAuthState = async (worktree, config) => {
347
- const authStateFile = path.resolve(worktree, config.authStatePath);
348
- const authState = await readAuthState(authStateFile);
349
- if (!authState) {
386
+ const authState = await readGlobalAuthState(config.authStatePath);
387
+ if (authState && Date.parse(authState.expiresAt) > Date.now()) {
388
+ return authState;
389
+ }
390
+ if (authState) {
391
+ await clearAuthState(config.authStatePath);
350
392
  return null;
351
393
  }
352
- if (Date.parse(authState.expiresAt) > Date.now()) {
353
- return authState;
394
+ const legacyAuthStateFile = path.resolve(worktree, LEGACY_AUTH_STATE_PATH);
395
+ const legacyAuthState = await readLegacyAuthState(legacyAuthStateFile);
396
+ if (!legacyAuthState) return null;
397
+ if (Date.parse(legacyAuthState.expiresAt) <= Date.now()) {
398
+ await deleteFileIfExists(legacyAuthStateFile);
399
+ return null;
354
400
  }
355
- await deleteFileIfExists(authStateFile);
356
- return null;
401
+ await writeAuthState(config.authStatePath, legacyAuthState);
402
+ await deleteFileIfExists(legacyAuthStateFile);
403
+ return legacyAuthState;
357
404
  };
358
405
  export const buildSkillMarkdown = item => {
359
406
  const artifactBody = item.publishedArtifact.markdownBody.trim();
@@ -480,6 +527,10 @@ export const toPublishedSkillCatalog = payload => ({
480
527
  facets: getPublishedSkillFacets(payload.skills),
481
528
  skills: payload.skills.map(toPublishedSkillSummary)
482
529
  });
530
+ const getWorkspaceUnavailableMessage = payload => {
531
+ if (payload.workspace) return null;
532
+ return 'Workspace-specific skills are unavailable because the workspace was not found; global skills are still loaded.';
533
+ };
483
534
  const normalizeSkillIdentifier = value => value.trim().toLowerCase();
484
535
  const parseSkillIdentifiers = value => {
485
536
  const seen = new Set();
@@ -561,7 +612,7 @@ const buildSystemNote = (result, config, details) => {
561
612
  const projectSkills = catalog.skills.filter(skill => skill.contextKind === 'project').slice(0, 5).map(buildSkillCatalogLine);
562
613
  const detailLines = details.slice(0, SYSTEM_NOTE_DETAIL_LIMIT).map(buildSkillDetailSnippetLine);
563
614
  const detailBlock = detailLines.length > 0 ? ` Loaded body snippets (capped):\n${truncateText(detailLines.join('\n'), SYSTEM_NOTE_DETAIL_CHAR_LIMIT)}` : '';
564
- return [`opencode-wizard published skills are available from backend runtime delivery for workspace ${result.fetchResult.payload.workspace.slug}.`, `Current directory: ${result.directoryPath}.`, `Published skills for this scope: ${renderedSkillNames}${renderedCountSuffix}; counts: ${catalog.assignmentCounts.global} global, ${catalog.assignmentCounts.project} project, ${catalog.assignmentCounts.other} other.`, 'Catalog lines use short whenToUse guidance when available; fetch the full skill only when that guidance matches the task.', 'GLOBAL_CONTEXT skills are active context skills and are not project-installable; PROJECT_INSTALLABLE skills can be assigned globally or to project/workspace scopes; assignment rows decide which skills are active here.', globalSkills.length > 0 ? `Global context skills:\n${globalSkills.join('\n')}` : 'Global context skills: none.', projectSkills.length > 0 ? `Project-scoped active skills:\n${projectSkills.join('\n')}` : 'Project-scoped active skills: none.', detailBlock, 'Use opencode_wizard_published_skills_fetch for one or multiple skills.', `Root source seed path remains non-runtime input only: ${config.rootSkillSeedPath}/**.`].filter(line => line.length > 0).join(' ');
615
+ return [result.fetchResult.payload.workspace ? `opencode-wizard published skills are available from backend runtime delivery for workspace ${result.fetchResult.payload.workspace.slug}.` : 'opencode-wizard published global skills are available from backend runtime delivery; workspace-specific skills are unavailable because the workspace was not found.', `Current directory: ${result.directoryPath}.`, `Published skills for this scope: ${renderedSkillNames}${renderedCountSuffix}; counts: ${catalog.assignmentCounts.global} global, ${catalog.assignmentCounts.project} project, ${catalog.assignmentCounts.other} other.`, 'Catalog lines use short whenToUse guidance when available; fetch the full skill only when that guidance matches the task.', 'GLOBAL_CONTEXT skills are active context skills and are not project-installable; PROJECT_INSTALLABLE skills can be assigned globally or to project/workspace scopes; assignment rows decide which skills are active here.', globalSkills.length > 0 ? `Global context skills:\n${globalSkills.join('\n')}` : 'Global context skills: none.', projectSkills.length > 0 ? `Project-scoped active skills:\n${projectSkills.join('\n')}` : 'Project-scoped active skills: none.', detailBlock, 'Use opencode_wizard_published_skills_fetch for one or multiple skills.', `Root source seed path remains non-runtime input only: ${config.rootSkillSeedPath}/**.`].filter(line => line.length > 0).join(' ');
565
616
  };
566
617
  const toWorkspaceResolutionOutput = resolution => ({
567
618
  requestedDirectory: resolution.requestedDirectory,
@@ -619,7 +670,8 @@ const formatStatusOutput = async (worktree, config, publishedSkillsResult, login
619
670
  }
620
671
  return JSON.stringify({
621
672
  ...base,
622
- ...toPublishedSkillCatalog(publishedSkillsResult.fetchResult.payload)
673
+ ...toPublishedSkillCatalog(publishedSkillsResult.fetchResult.payload),
674
+ message: getWorkspaceUnavailableMessage(publishedSkillsResult.fetchResult.payload)
623
675
  }, null, 2);
624
676
  };
625
677
  export const toPluginAuthStateSummary = authState => {
@@ -668,10 +720,80 @@ export const resolvePluginStatusSnapshot = async ({
668
720
  fetchedAt: fetchResult.fetchedAt,
669
721
  source: fetchResult.source,
670
722
  availableTools: AVAILABLE_PUBLISHED_SKILL_TOOLS,
671
- message: fetchResult.ok ? null : fetchResult.message,
723
+ message: fetchResult.ok ? getWorkspaceUnavailableMessage(fetchResult.payload) : fetchResult.message,
672
724
  catalog: fetchResult.ok ? toPublishedSkillCatalog(fetchResult.payload) : null
673
725
  };
674
726
  };
727
+ const withStatusMessage = (snapshot, message) => ({
728
+ ...snapshot,
729
+ message
730
+ });
731
+ const startStatusPathLoginBootstrap = (worktree, config) => {
732
+ if (statusPathLoginBootstrap.promise) return;
733
+ if (statusPathLoginBootstrap.status === 'failed' && statusPathLoginBootstrap.failedAt && Date.now() - statusPathLoginBootstrap.failedAt < STATUS_PATH_LOGIN_RETRY_COOLDOWN_MS) {
734
+ return;
735
+ }
736
+ statusPathLoginBootstrap.status = 'pending';
737
+ statusPathLoginBootstrap.message = 'Browser login started automatically from the TUI/status path.';
738
+ statusPathLoginBootstrap.failedAt = null;
739
+ statusPathLoginBootstrap.promise = (async () => {
740
+ const loginSignal = AbortSignal.timeout(LOGIN_TIMEOUT_MS);
741
+ const loginStart = await startLoginFlow(loginSignal);
742
+ const browserOpenError = await openBrowser(loginStart.browserUrl);
743
+ if (browserOpenError) {
744
+ statusPathLoginBootstrap.message = `Automatic browser open failed. Open ${loginStart.browserUrl} manually.`;
745
+ }
746
+ try {
747
+ const callbackPayload = await loginStart.callbackPromise;
748
+ if (callbackPayload.status === 'error') {
749
+ throw new Error(callbackPayload.message);
750
+ }
751
+ if (callbackPayload.state !== loginStart.expectedState) {
752
+ throw new Error('OAuth callback state did not match the original login request.');
753
+ }
754
+ const pluginSession = await createPluginSession({
755
+ code: callbackPayload.code,
756
+ codeVerifier: loginStart.codeVerifier,
757
+ redirectUri: OIDC_CALLBACK_URL,
758
+ config,
759
+ signal: loginSignal
760
+ });
761
+ const authState = toAuthState(pluginSession);
762
+ await writeAuthState(config.authStatePath, authState);
763
+ statusPathLoginBootstrap.status = 'authenticated';
764
+ statusPathLoginBootstrap.message = `Browser login completed successfully for ${authState.email}.`;
765
+ return authState;
766
+ } finally {
767
+ await loginStart.closeCallbackServer().catch(() => undefined);
768
+ }
769
+ })().catch(error => {
770
+ statusPathLoginBootstrap.status = 'failed';
771
+ statusPathLoginBootstrap.failedAt = Date.now();
772
+ statusPathLoginBootstrap.message = error instanceof Error ? error.message : 'Browser login failed.';
773
+ throw error;
774
+ }).finally(() => {
775
+ statusPathLoginBootstrap.promise = null;
776
+ });
777
+ statusPathLoginBootstrap.promise.catch(() => undefined);
778
+ };
779
+ export const resolvePluginStatusSnapshotWithAuthBootstrap = async ({
780
+ worktree,
781
+ directory,
782
+ signal
783
+ }) => {
784
+ const snapshot = await resolvePluginStatusSnapshot({
785
+ worktree,
786
+ directory,
787
+ signal
788
+ });
789
+ if (snapshot.status !== 'missing_auth') return snapshot;
790
+ const config = await resolveConfig(worktree);
791
+ startStatusPathLoginBootstrap(worktree, config);
792
+ if (statusPathLoginBootstrap.message) {
793
+ return withStatusMessage(snapshot, statusPathLoginBootstrap.message);
794
+ }
795
+ return withStatusMessage(snapshot, 'Browser login is pending from the TUI/status path.');
796
+ };
675
797
  const toPluginStatusMetadata = snapshot => ({
676
798
  backendOrigin: snapshot.backendOrigin,
677
799
  graphqlUrl: snapshot.graphqlUrl,
@@ -714,12 +836,204 @@ const fetchOidcDiscoveryDocument = async signal => {
714
836
  }
715
837
  return await response.json();
716
838
  };
839
+ const isCallbackPortInUseError = error => {
840
+ if (!error || typeof error !== 'object') return false;
841
+ if (!('code' in error)) return false;
842
+ return error.code === 'EADDRINUSE';
843
+ };
844
+ const toCallbackServerStartError = error => {
845
+ if (!isCallbackPortInUseError(error)) {
846
+ return error instanceof Error ? error : new Error('Failed to start local OAuth callback server.');
847
+ }
848
+ return new Error('OAuth login cannot start because localhost:24953 is already in use. Another OpenCode login is likely in progress; finish it or close the other instance, then retry.');
849
+ };
850
+ const escapeHtml = value => {
851
+ return value.replace(/[&<>'"]/g, character => {
852
+ const replacements = {
853
+ '&': '&amp;',
854
+ '<': '&lt;',
855
+ '>': '&gt;',
856
+ "'": '&#39;',
857
+ '"': '&quot;'
858
+ };
859
+ return replacements[character] ?? character;
860
+ });
861
+ };
717
862
  const sendHtmlResponse = (response, statusCode, title, message) => {
863
+ const escapedTitle = escapeHtml(title);
864
+ const escapedMessage = escapeHtml(message);
865
+ const isSuccess = statusCode >= 200 && statusCode < 300;
866
+ const pageState = isSuccess ? 'success' : statusCode === 404 ? 'not-found' : 'error';
867
+ const cardTitle = isSuccess ? 'Authorization successful' : statusCode === 404 ? 'Callback not found' : 'Authorization failed';
868
+ const escapedCardTitle = escapeHtml(cardTitle);
869
+ const eyebrow = isSuccess ? 'Authorization complete' : statusCode === 404 ? 'Callback route not found' : 'Authorization needs attention';
870
+ const actionText = isSuccess ? 'This window will close automatically in a moment. You can also close it now and return to OpenCode.' : 'You can close this window and return to OpenCode to try again.';
871
+ const autoCloseScript = isSuccess ? `<script>
872
+ window.setTimeout(() => window.close(), 2000);
873
+ </script>` : '';
874
+ const stateIcon = isSuccess ? '<path d="M7 12.5l3.1 3.1L17.5 8" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>' : statusCode === 404 ? '<path d="M10.5 17a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13Z" stroke="currentColor" stroke-width="2.2"/><path d="m15.5 15.5 4 4" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>' : '<path d="M12 7v6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/><path d="M12 17.2v.1" stroke="currentColor" stroke-width="3.2" stroke-linecap="round"/>';
718
875
  response.writeHead(statusCode, {
719
876
  'content-type': 'text/html; charset=utf-8',
720
877
  'cache-control': 'no-store'
721
878
  });
722
- response.end(`<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body><h1>${title}</h1><p>${message}</p><p>You can close this window and return to OpenCode.</p></body></html>`);
879
+ response.end(`<!doctype html>
880
+ <html lang="en">
881
+ <head>
882
+ <meta charset="utf-8">
883
+ <meta name="viewport" content="width=device-width, initial-scale=1">
884
+ <meta name="color-scheme" content="light dark">
885
+ <title>${escapedTitle}</title>
886
+ <style>
887
+ :root {
888
+ color-scheme: light dark;
889
+ --page-bg: #f2efe7;
890
+ --page-ink: #211d18;
891
+ --muted: #6c6258;
892
+ --panel: rgba(255, 252, 245, 0.82);
893
+ --panel-border: rgba(78, 66, 52, 0.16);
894
+ --success: #167848;
895
+ --error: #ba3329;
896
+ --not-found: #986614;
897
+ --glow: rgba(22, 120, 72, 0.18);
898
+ }
899
+
900
+ @media (prefers-color-scheme: dark) {
901
+ :root {
902
+ --page-bg: #12100d;
903
+ --page-ink: #f7efe2;
904
+ --muted: #b8aa98;
905
+ --panel: rgba(30, 26, 22, 0.78);
906
+ --panel-border: rgba(255, 244, 224, 0.14);
907
+ --success: #71e0a6;
908
+ --error: #ff897e;
909
+ --not-found: #f7c96f;
910
+ --glow: rgba(113, 224, 166, 0.2);
911
+ }
912
+ }
913
+
914
+ * {
915
+ box-sizing: border-box;
916
+ }
917
+
918
+ body {
919
+ min-height: 100vh;
920
+ margin: 0;
921
+ display: grid;
922
+ place-items: center;
923
+ padding: 24px;
924
+ overflow: hidden;
925
+ background:
926
+ radial-gradient(circle at 18% 18%, var(--glow), transparent 34rem),
927
+ radial-gradient(circle at 82% 12%, rgba(209, 142, 72, 0.18), transparent 30rem),
928
+ linear-gradient(135deg, var(--page-bg), color-mix(in srgb, var(--page-bg) 76%, #000 24%));
929
+ color: var(--page-ink);
930
+ font-family: ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, sans-serif;
931
+ }
932
+
933
+ body::before {
934
+ content: "";
935
+ position: fixed;
936
+ inset: -20%;
937
+ pointer-events: none;
938
+ background-image:
939
+ linear-gradient(rgba(128, 104, 74, 0.08) 1px, transparent 1px),
940
+ linear-gradient(90deg, rgba(128, 104, 74, 0.08) 1px, transparent 1px);
941
+ background-size: 42px 42px;
942
+ mask-image: radial-gradient(circle at center, black, transparent 68%);
943
+ }
944
+
945
+ main {
946
+ position: relative;
947
+ width: min(100%, 560px);
948
+ padding: clamp(28px, 7vw, 56px);
949
+ border: 1px solid var(--panel-border);
950
+ border-radius: 32px;
951
+ background: var(--panel);
952
+ box-shadow: 0 24px 90px rgba(0, 0, 0, 0.24);
953
+ text-align: center;
954
+ backdrop-filter: blur(18px) saturate(1.2);
955
+ }
956
+
957
+ .mark {
958
+ width: 72px;
959
+ height: 72px;
960
+ margin: 0 auto 24px;
961
+ display: grid;
962
+ place-items: center;
963
+ border-radius: 24px;
964
+ color: var(--state-color);
965
+ background: color-mix(in srgb, var(--state-color) 16%, transparent);
966
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--state-color) 28%, transparent);
967
+ }
968
+
969
+ [data-state="success"] { --state-color: var(--success); }
970
+ [data-state="error"] { --state-color: var(--error); }
971
+ [data-state="not-found"] { --state-color: var(--not-found); }
972
+
973
+ .eyebrow {
974
+ margin: 0 0 10px;
975
+ color: var(--state-color);
976
+ font-size: 0.78rem;
977
+ font-weight: 800;
978
+ letter-spacing: 0.14em;
979
+ text-transform: uppercase;
980
+ }
981
+
982
+ h1 {
983
+ margin: 0;
984
+ font-size: clamp(2rem, 7vw, 3.35rem);
985
+ line-height: 0.95;
986
+ letter-spacing: -0.06em;
987
+ }
988
+
989
+ .message {
990
+ margin: 22px auto 0;
991
+ max-width: 38rem;
992
+ color: var(--muted);
993
+ font-size: clamp(1rem, 2.5vw, 1.1rem);
994
+ line-height: 1.65;
995
+ }
996
+
997
+ .next-step {
998
+ margin: 26px 0 0;
999
+ padding: 14px 18px;
1000
+ border-radius: 999px;
1001
+ background: color-mix(in srgb, var(--state-color) 12%, transparent);
1002
+ color: var(--page-ink);
1003
+ font-size: 0.94rem;
1004
+ line-height: 1.5;
1005
+ }
1006
+
1007
+ @media (max-width: 520px) {
1008
+ body {
1009
+ padding: 16px;
1010
+ }
1011
+
1012
+ main {
1013
+ border-radius: 24px;
1014
+ }
1015
+
1016
+ .next-step {
1017
+ border-radius: 18px;
1018
+ }
1019
+ }
1020
+ </style>
1021
+ </head>
1022
+ <body>
1023
+ <main data-state="${pageState}" aria-labelledby="callback-title">
1024
+ <div class="mark" aria-hidden="true">
1025
+ <svg width="34" height="34" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1026
+ ${stateIcon}
1027
+ </svg>
1028
+ </div>
1029
+ <p class="eyebrow">${eyebrow}</p>
1030
+ <h1 id="callback-title">${escapedCardTitle}</h1>
1031
+ <p class="message">${escapedMessage}</p>
1032
+ <p class="next-step">${actionText}</p>
1033
+ </main>
1034
+ ${autoCloseScript}
1035
+ </body>
1036
+ </html>`);
723
1037
  };
724
1038
  const startLocalCallbackServer = async ({
725
1039
  expectedState,
@@ -784,9 +1098,6 @@ const startLocalCallbackServer = async ({
784
1098
  state
785
1099
  });
786
1100
  });
787
- server.on('error', error => {
788
- fail(error instanceof Error ? error : new Error('Failed to start local OAuth callback server.'));
789
- });
790
1101
  const close = async () => {
791
1102
  await new Promise((resolve, reject) => {
792
1103
  server.close(error => {
@@ -799,8 +1110,17 @@ const startLocalCallbackServer = async ({
799
1110
  });
800
1111
  };
801
1112
  await new Promise((resolve, reject) => {
802
- server.listen(24953, 'localhost', () => resolve());
803
- server.once('error', reject);
1113
+ const rejectStart = error => {
1114
+ reject(toCallbackServerStartError(error));
1115
+ };
1116
+ server.once('error', rejectStart);
1117
+ server.listen(24953, 'localhost', () => {
1118
+ server.off('error', rejectStart);
1119
+ server.on('error', error => {
1120
+ fail(error instanceof Error ? error : new Error('Local OAuth callback server failed.'));
1121
+ });
1122
+ resolve();
1123
+ });
804
1124
  });
805
1125
  signal.addEventListener('abort', () => {
806
1126
  fail(signal.reason instanceof Error ? signal.reason : new Error('OAuth login aborted.'));
@@ -864,7 +1184,7 @@ const fetchPublishedSkillsGraphQl = async ({
864
1184
  };
865
1185
  }
866
1186
  if (response.status === 401 || response.status === 403) {
867
- await deleteFileIfExists(path.resolve(worktree, config.authStatePath));
1187
+ await clearAuthState(config.authStatePath);
868
1188
  onAuthStateChanged?.();
869
1189
  return {
870
1190
  ok: false,
@@ -910,7 +1230,7 @@ const fetchPublishedSkillsGraphQl = async ({
910
1230
  if (body.errors?.length) {
911
1231
  const message = body.errors.map(error => error.message).join('; ');
912
1232
  if (body.errors.some(error => isUnauthorizedGraphQlMessage(error.message))) {
913
- await deleteFileIfExists(path.resolve(worktree, config.authStatePath));
1233
+ await clearAuthState(config.authStatePath);
914
1234
  onAuthStateChanged?.();
915
1235
  return {
916
1236
  ok: false,
@@ -1199,7 +1519,6 @@ const OpencodeWizardSkillsPlugin = async input => {
1199
1519
  const catalogInflight = new Map();
1200
1520
  const detailCache = new Map();
1201
1521
  const detailInflight = new Map();
1202
- const authStateFile = path.resolve(input.worktree, config.authStatePath);
1203
1522
  const initialAuthState = await resolveStoredAuthState(input.worktree, config);
1204
1523
  const loginBootstrap = {
1205
1524
  promise: null,
@@ -1298,7 +1617,7 @@ const OpencodeWizardSkillsPlugin = async input => {
1298
1617
  };
1299
1618
  const persistAuthState = async session => {
1300
1619
  const authState = toAuthState(session);
1301
- await writeAuthState(authStateFile, authState);
1620
+ await writeAuthState(config.authStatePath, authState);
1302
1621
  clearPublishedSkillState();
1303
1622
  return authState;
1304
1623
  };
@@ -1319,19 +1638,20 @@ const OpencodeWizardSkillsPlugin = async input => {
1319
1638
  };
1320
1639
  const loginPromise = (async () => {
1321
1640
  const loginSignal = AbortSignal.timeout(LOGIN_TIMEOUT_MS);
1322
- const loginStart = await startLoginFlow(loginSignal);
1323
- const browserOpenError = await openBrowser(loginStart.browserUrl);
1324
- loginBootstrap.snapshot = {
1325
- status: 'pending',
1326
- trigger,
1327
- startedAt,
1328
- expiresAt: loginStart.expiresAt,
1329
- browserUrl: loginStart.browserUrl,
1330
- browserOpenError,
1331
- email: null,
1332
- message: browserOpenError ? `Automatic browser open failed. Open ${loginStart.browserUrl} manually.` : `Browser login started for published skill ${trigger}.`
1333
- };
1641
+ let loginStart = null;
1334
1642
  try {
1643
+ loginStart = await startLoginFlow(loginSignal);
1644
+ const browserOpenError = await openBrowser(loginStart.browserUrl);
1645
+ loginBootstrap.snapshot = {
1646
+ status: 'pending',
1647
+ trigger,
1648
+ startedAt,
1649
+ expiresAt: loginStart.expiresAt,
1650
+ browserUrl: loginStart.browserUrl,
1651
+ browserOpenError,
1652
+ email: null,
1653
+ message: browserOpenError ? `Automatic browser open failed. Open ${loginStart.browserUrl} manually.` : `Browser login started for published skill ${trigger}.`
1654
+ };
1335
1655
  const callbackPayload = await loginStart.callbackPromise;
1336
1656
  if (callbackPayload.status === 'error') {
1337
1657
  throw new Error(callbackPayload.message);
@@ -1390,7 +1710,7 @@ const OpencodeWizardSkillsPlugin = async input => {
1390
1710
  };
1391
1711
  throw error;
1392
1712
  } finally {
1393
- await loginStart.closeCallbackServer().catch(() => undefined);
1713
+ await loginStart?.closeCallbackServer().catch(() => undefined);
1394
1714
  loginBootstrap.promise = null;
1395
1715
  }
1396
1716
  })();