Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 103 additions & 2 deletions app/components/Header/ConnectorModal.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,75 @@
<script setup lang="ts">
const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disconnect } =
useConnector()
const {
isConnected,
isConnecting,
npmUser,
error,
hasOperations,
operations,
connect,
disconnect,
refreshState,
} = useConnector()

const { settings } = useSettings()

const authUrl = computed(() => {
const op = operations.value.find(o => o.status === 'running' && o.authUrl)
return op?.authUrl ?? null
})

const AUTH_POLL_INTERVAL = 20_000
const AUTH_POLL_COUNT = 3
let authPollTimer: ReturnType<typeof setInterval> | null = null

function startAuthPolling() {
stopAuthPolling()
let remaining = AUTH_POLL_COUNT
authPollTimer = setInterval(async () => {
try {
await refreshState()
} catch {
stopAuthPolling()
return
}
remaining--
if (remaining <= 0) {
stopAuthPolling()
}
}, AUTH_POLL_INTERVAL)
}

function stopAuthPolling() {
if (authPollTimer) {
clearInterval(authPollTimer)
authPollTimer = null
}
}

onUnmounted(stopAuthPolling)

function handleOpenAuthUrl() {
if (authUrl.value) {
window.open(authUrl.value, '_blank', 'noopener,noreferrer')
startAuthPolling()
}
}

const tokenInput = shallowRef('')
const portInput = shallowRef('31415')
const { copied, copy } = useClipboard({ copiedDuring: 2000 })

const hasAttemptedConnect = shallowRef(false)

watch(
() => settings.value.connector.webAuth,
webAuth => {
if (!webAuth) {
settings.value.connector.autoOpenURL = false
}
},
)

watch(isConnected, connected => {
if (!connected) {
tokenInput.value = ''
Expand Down Expand Up @@ -61,13 +123,39 @@ function handleDisconnect() {
</div>
</div>

<!-- Connector preferences -->
<div class="flex flex-col gap-2">
<SettingsToggle
:label="$t('connector.modal.web_auth')"
v-model="settings.connector.webAuth"
/>
<SettingsToggle
:label="$t('connector.modal.auto_open_url')"
v-model="settings.connector.autoOpenURL"
:class="!settings.connector.webAuth ? 'opacity-50 pointer-events-none' : ''"
/>
</div>

<div class="border-t border-border my-3" />

<!-- Operations Queue -->
<OrgOperationsQueue />

<div v-if="!hasOperations" class="text-sm text-fg-muted">
{{ $t('connector.modal.connected_hint') }}
</div>

<!-- Web auth link -->
<button
v-if="authUrl"
type="button"
class="flex items-center justify-center gap-2 w-full px-4 py-2 font-mono text-sm text-accent bg-accent/10 border border-accent/30 rounded-md transition-colors duration-200 hover:bg-accent/20"
@click="handleOpenAuthUrl"
>
<span class="i-carbon:launch w-4 h-4" aria-hidden="true" />
{{ $t('operations.queue.open_web_auth') }}
</button>

<button
type="button"
class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-accent/70"
Expand Down Expand Up @@ -194,6 +282,19 @@ function handleDisconnect() {
class="w-full"
size="medium"
/>

<div class="border-t border-border my-3" />
<div class="flex flex-col gap-2">
<SettingsToggle
:label="$t('connector.modal.web_auth')"
v-model="settings.connector.webAuth"
/>
<SettingsToggle
:label="$t('connector.modal.auto_open_url')"
v-model="settings.connector.autoOpenURL"
:class="!settings.connector.webAuth ? 'opacity-50 pointer-events-none' : ''"
/>
</div>
</div>
</details>
</div>
Expand Down
23 changes: 23 additions & 0 deletions app/components/Org/OperationsQueue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const {
refreshState,
} = useConnector()

const { settings } = useSettings()

const isExecuting = shallowRef(false)
const otpInput = shallowRef('')

Expand Down Expand Up @@ -63,6 +65,18 @@ async function handleRetryWithOtp() {
await handleExecute(otp)
}

/** Retry all OTP-failed operations using web auth (no OTP needed) */
async function handleRetryWithWebAuth() {
const otpFailedOps = activeOperations.value.filter(
(op: PendingOperation) => op.status === 'failed' && op.result?.requiresOtp,
)
for (const op of otpFailedOps) {
await retryOperation(op.id)
}

await handleExecute()
}

async function handleClearAll() {
await clearOperations()
otpInput.value = ''
Expand Down Expand Up @@ -263,6 +277,15 @@ watch(isExecuting, executing => {
{{ isExecuting ? $t('operations.queue.retrying') : $t('operations.queue.retry_otp') }}
</button>
</form>
<button
v-if="settings.connector.webAuth"
type="button"
:disabled="isExecuting"
class="w-full mt-2 px-3 py-2 font-mono text-xs text-fg bg-bg-subtle border border-border rounded transition-all duration-200 hover:text-fg hover:border-border-hover disabled:opacity-50 disabled:cursor-not-allowed"
@click="handleRetryWithWebAuth"
>
{{ isExecuting ? $t('operations.queue.retrying') : $t('operations.queue.retry_web_auth') }}
</button>
</div>

<!-- Action buttons -->
Expand Down
8 changes: 7 additions & 1 deletion app/composables/useConnector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const STORAGE_KEY = 'npmx-connector'
const DEFAULT_PORT = 31415

export const useConnector = createSharedComposable(function useConnector() {
const { settings } = useSettings()

// Persisted connection config
const config = useState<{ token: string; port: number } | null>('connector-config', () => null)

Expand Down Expand Up @@ -303,7 +305,11 @@ export const useConnector = createSharedComposable(function useConnector() {
ApiResponse<{ results: unknown[]; otpRequired?: boolean }>
>('/execute', {
method: 'POST',
body: otp ? { otp } : undefined,
body: {
otp,
interactive: settings.value.connector.webAuth,
openUrls: settings.value.connector.autoOpenURL,
},
})
if (response?.success) {
await refreshState()
Expand Down
11 changes: 11 additions & 0 deletions app/composables/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export interface AppSettings {
selectedLocale: LocaleObject['code'] | null
/** Search provider for package search */
searchProvider: SearchProvider
/** Connector preferences */
connector: {
/** Use web-based authentication instead of CLI token */
webAuth: boolean
/** Automatically open the web auth page in the browser */
autoOpenURL: boolean
}
sidebar: {
collapsed: string[]
}
Expand All @@ -42,6 +49,10 @@ const DEFAULT_SETTINGS: AppSettings = {
selectedLocale: null,
preferredBackgroundTheme: null,
searchProvider: import.meta.test ? 'npm' : 'algolia',
connector: {
webAuth: false,
autoOpenURL: false,
},
sidebar: {
collapsed: [],
},
Expand Down
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@lydell/node-pty": "1.2.0-beta.3",
"citty": "^0.2.0",
"h3-next": "npm:h3@^2.0.1-rc.11",
"obug": "^2.1.1",
Expand Down
11 changes: 8 additions & 3 deletions cli/src/mock-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,13 +230,18 @@ function createMockConnectorApp(stateManager: MockConnectorStateManager) {
requireAuth(event)

const body = await event.req.json().catch(() => ({}))
const otp = (body as { otp?: string })?.otp
const { otp } = body as { otp?: string; interactive?: boolean; openUrls?: boolean }

const { results, otpRequired } = stateManager.executeOperations({ otp })
const { results, otpRequired, authFailure, urls } = stateManager.executeOperations({ otp })

return {
success: true,
data: { results, otpRequired },
data: {
results,
otpRequired,
authFailure,
urls,
},
} satisfies ApiResponse<ConnectorEndpoints['POST /execute']['data']>
})

Expand Down
12 changes: 11 additions & 1 deletion cli/src/mock-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface ExecuteOptions {
export interface ExecuteResult {
results: Array<{ id: string; result: OperationResult }>
otpRequired?: boolean
authFailure?: boolean
urls?: string[]
}

export function createMockConnectorState(config: MockConnectorConfig): MockConnectorStateData {
Expand Down Expand Up @@ -305,7 +307,15 @@ export class MockConnectorStateManager {
}
}

return { results }
const authFailure = results.some(r => r.result.authFailure)
const allUrls = results.flatMap(r => r.result.urls ?? [])
const urls = [...new Set(allUrls)]

return {
results,
authFailure: authFailure || undefined,
urls: urls.length > 0 ? urls : undefined,
}
}

/** Apply side effects of a completed operation. Param keys match schemas.ts. */
Expand Down
53 changes: 53 additions & 0 deletions cli/src/node-pty.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// @lydell/node-pty package.json does not export its types so for nodenext target we need to add them
declare module '@lydell/node-pty' {
export function spawn(
file: string,
args: string[] | string,
options: IPtyForkOptions | IWindowsPtyForkOptions,
): IPty
export interface IBasePtyForkOptions {
name?: string
cols?: number
rows?: number
cwd?: string
env?: { [key: string]: string | undefined }
encoding?: string | null
handleFlowControl?: boolean
flowControlPause?: string
flowControlResume?: string
}

export interface IPtyForkOptions extends IBasePtyForkOptions {
uid?: number
gid?: number
}

export interface IWindowsPtyForkOptions extends IBasePtyForkOptions {
useConpty?: boolean
useConptyDll?: boolean
conptyInheritCursor?: boolean
}

export interface IPty {
readonly pid: number
readonly cols: number
readonly rows: number
readonly process: string
handleFlowControl: boolean
readonly onData: IEvent<string>
readonly onExit: IEvent<{ exitCode: number; signal?: number }>
resize(columns: number, rows: number): void
clear(): void
write(data: string | Buffer): void
kill(signal?: string): void
pause(): void
resume(): void
}

export interface IDisposable {
dispose(): void
}
export interface IEvent<T> {
(listener: (e: T) => any): IDisposable
}
}
Loading
Loading