Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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>
Comment thread
mikouaji marked this conversation as resolved.
</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 @@ -33,6 +33,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@lydell/node-pty": "1.2.0-beta.3",
Comment thread
mikouaji marked this conversation as resolved.
"citty": "^0.2.0",
"h3-next": "npm:h3@^2.0.1-rc.11",
"obug": "^2.1.1",
Expand Down
29 changes: 29 additions & 0 deletions cli/src/node-pty.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// @lydell/node-pty package.json does not export its types so for nodenext target we need to add them
declare module '@lydell/node-pty' {
interface IDisposable {
dispose(): void
}

interface IEvent<T> {
(listener: (e: T) => any): IDisposable
}

interface IPty {
readonly pid: number
readonly onData: IEvent<string>
readonly onExit: IEvent<{ exitCode: number; signal?: number }>
write(data: string | Buffer): void
kill(signal?: string): void
}

export function spawn(
file: string,
args: string[] | string,
options: {
name?: string
cols?: number
rows?: number
env?: Record<string, string | undefined>
},
): IPty
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
Loading
Loading