Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ test-results

# Test coverage
coverage/
*.junit.xml

# Playwright
playwright-report/
Expand Down
274 changes: 274 additions & 0 deletions app/components/Package/DeprecatePackageModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
<script setup lang="ts">
import type { NewOperation } from '~/composables/useConnector'
import * as v from 'valibot'
import { PackageDeprecateParamsSchema } from '#shared/schemas/package'

const DEPRECATE_MESSAGE_MAX_LENGTH = 500

const props = withDefaults(
defineProps<{
packageName: string
version?: string
/** When true, the package or version is already deprecated; form is hidden and state cannot be changed. */
isAlreadyDeprecated?: boolean
/** Version strings that are already deprecated (computed by parent from pkg.versions). */
deprecatedVersions?: string[]
}>(),
{ version: '', isAlreadyDeprecated: false, deprecatedVersions: () => [] },
)

const { t } = useI18n()
const { isConnected, state, addOperation, approveOperation, executeOperations, refreshState } =
useConnector()

const deprecateMessage = ref('')
const deprecateVersion = ref(props.version)
const isDeprecating = shallowRef(false)
const deprecateSuccess = shallowRef(false)
const deprecateError = shallowRef<string | null>(null)

const connectorModal = useModal('connector-modal')

const modalTitle = computed(() =>
deprecateVersion.value
? `${t('package.deprecation.modal.title')} ${props.packageName}@${deprecateVersion.value}`
: `${t('package.deprecation.modal.title')} ${props.packageName}`,
)
Comment thread
eryue0220 marked this conversation as resolved.

/** True when the user has entered a version in the form that is already deprecated. */
const isSelectedVersionDeprecated = computed(() => {
const v = deprecateVersion.value.trim()

Check warning on line 40 in app/components/Package/DeprecatePackageModal.vue

View workflow job for this annotation

GitHub Actions / 🤖 Autofix code

eslint(no-shadow)

'v' is already declared in the upper scope.

Check warning on line 40 in app/components/Package/DeprecatePackageModal.vue

View workflow job for this annotation

GitHub Actions / 🔠 Lint project

eslint(no-shadow)

'v' is already declared in the upper scope.
if (!v || !props.deprecatedVersions.length) return false
return props.deprecatedVersions.includes(v)
})

async function handleDeprecate() {
if (props.isAlreadyDeprecated || isSelectedVersionDeprecated.value) return
const message = deprecateMessage.value.trim()
if (!isConnected.value) return

const params: Record<string, string> = {
pkg: props.packageName,
message,
}
if (deprecateVersion.value.trim()) {
params.version = deprecateVersion.value.trim()
}

const parsed = v.safeParse(PackageDeprecateParamsSchema, params)
if (!parsed.success) {
const firstIssue = parsed.issues[0]
const path = firstIssue?.path?.map(p => p.key).join('.') || ''
const message = firstIssue?.message || 'Validation failed'

Check warning on line 62 in app/components/Package/DeprecatePackageModal.vue

View workflow job for this annotation

GitHub Actions / 🤖 Autofix code

eslint(no-shadow)

'message' is already declared in the upper scope.

Check warning on line 62 in app/components/Package/DeprecatePackageModal.vue

View workflow job for this annotation

GitHub Actions / 🔠 Lint project

eslint(no-shadow)

'message' is already declared in the upper scope.
deprecateError.value = path ? `${path}: ${message}` : message
return
}

isDeprecating.value = true
deprecateError.value = null

try {
const escapedMessage = parsed.output.message.replace(/"/g, '\\"')
const command = parsed.output.version
? `npm deprecate ${parsed.output.pkg}@${parsed.output.version} "${escapedMessage}"`
: `npm deprecate ${parsed.output.pkg} "${escapedMessage}"`

const operation = await addOperation({
type: 'package:deprecate',
params: {
pkg: parsed.output.pkg,
message: parsed.output.message,
...(parsed.output.version && { version: parsed.output.version }),
},
description: parsed.output.version
? `Deprecate ${parsed.output.pkg}@${parsed.output.version}`
: `Deprecate ${parsed.output.pkg}`,
command,
} as NewOperation)

if (!operation) {
throw new Error('Failed to create operation')
}

await approveOperation(operation.id)
await executeOperations()
await refreshState()

const completedOp = state.value.operations.find(op => op.id === operation.id)
if (completedOp?.status === 'completed') {
deprecateSuccess.value = true
} else if (completedOp?.status === 'failed') {
if (completedOp.result?.requiresOtp) {
close()
connectorModal.open()
} else {
deprecateError.value = completedOp.result?.stderr || t('common.try_again')
}
} else {
close()
connectorModal.open()
}
} catch (err) {
deprecateError.value = err instanceof Error ? err.message : t('common.try_again')
} finally {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
isDeprecating.value = false
}
}

const dialogRef = useTemplateRef('dialogRef')

function open() {
deprecateError.value = null
deprecateSuccess.value = false
deprecateMessage.value = ''
deprecateVersion.value = props.version ?? ''
dialogRef.value?.showModal()
}

function close() {
dialogRef.value?.close()
}

defineExpose({ open, close })
</script>

<template>
<Modal ref="dialogRef" :modal-title="modalTitle" id="deprecate-package-modal" class="max-w-md">
<!-- Already deprecated: entire module read-only, hint only, no form / no deprecate button -->
<div v-if="isAlreadyDeprecated" class="space-y-4" aria-readonly="true">
<div
class="flex items-center gap-3 p-4 bg-amber-500/10 border border-amber-500/20 rounded-lg"
role="status"
>
<span class="i-carbon-warning-alt text-amber-500 w-6 h-6 shrink-0" aria-hidden="true" />
<div>
<p class="font-mono text-sm text-fg">
{{
deprecateVersion
? $t('package.deprecation.modal.already_deprecated_version')
: $t('package.deprecation.modal.already_deprecated')
}}
</p>
<p class="text-xs text-fg-muted mt-0.5">
{{ $t('package.deprecation.modal.already_deprecated_detail') }}
</p>
</div>
</div>
<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"
@click="close"
>
{{ $t('common.close') }}
</button>
Comment on lines +157 to +163
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove inline focus-visible utilities on buttons.
These button focus styles should rely on the shared global rule to keep behaviour consistent.

💡 Suggested fix
-        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-none focus-visible:ring-2 focus-visible:ring-fg/50"
+        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"
-        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-none focus-visible:ring-2 focus-visible:ring-fg/50"
+        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"
-        class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
+        class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed"
Based on learnings: In the npmx.dev project, ensure that focus-visible styling for button and select elements is implemented globally in app/assets/main.css with the rule: button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }. Do not apply per-element inline utility classes like focus-visible:outline-accent/70 on these elements in Vue templates or components (e.g., AccessControls.vue).

Also applies to: 199-205, 272-276

</div>

<!-- Success state -->
<div v-else-if="deprecateSuccess" class="space-y-4">
<div
class="flex items-center gap-3 p-4 bg-green-500/10 border border-green-500/20 rounded-lg"
>
<span class="i-carbon-checkmark-filled text-green-500 w-6 h-6" aria-hidden="true" />
<div>
<p class="font-mono text-sm text-fg">{{ $t('package.deprecation.modal.success') }}</p>
<p class="text-xs text-fg-muted">
{{ $t('package.deprecation.modal.success_detail') }}
</p>
</div>
</div>
<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"
@click="close"
>
{{ $t('common.close') }}
</button>
</div>

<!-- Form -->
<div v-else class="space-y-4">
<!-- Hint when user-entered version is already deprecated -->
<div
v-if="isSelectedVersionDeprecated"
class="flex items-center gap-3 p-4 bg-amber-500/10 border border-amber-500/20 rounded-lg"
role="status"
>
<span class="i-carbon-warning-alt text-amber-500 w-6 h-6 shrink-0" aria-hidden="true" />
<div>
<p class="font-mono text-sm text-fg">
{{ $t('package.deprecation.modal.already_deprecated_version') }}
</p>
<p class="text-xs text-fg-muted mt-0.5">
{{ $t('package.deprecation.modal.already_deprecated_detail') }}
</p>
</div>
</div>
<div>
<label
for="deprecate-message"
class="block text-xs text-fg-subtle uppercase tracking-wider mb-1.5"
>
{{ $t('package.deprecation.modal.reason') }}
</label>
<textarea
id="deprecate-message"
v-model="deprecateMessage"
rows="3"
:maxlength="DEPRECATE_MESSAGE_MAX_LENGTH"
:disabled="isSelectedVersionDeprecated"
class="w-full appearance-none bg-bg-subtle border border-border font-mono text-sm leading-none px-3 py-2.5 rounded-lg text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent outline-offset-2 focus:border-accent focus-visible:outline-accent/70 disabled:(opacity-50 cursor-not-allowed)"
:placeholder="$t('package.deprecation.modal.reason_placeholder')"
:aria-describedby="
deprecateMessage.length >= DEPRECATE_MESSAGE_MAX_LENGTH
? 'deprecate-message-hint'
: undefined
"
/>
<p
v-if="deprecateMessage.length >= DEPRECATE_MESSAGE_MAX_LENGTH * 0.9"
id="deprecate-message-hint"
class="mt-1 text-xs text-fg-muted"
>
{{ deprecateMessage.length }} / {{ DEPRECATE_MESSAGE_MAX_LENGTH }}
</p>
</div>
<div>
<label
for="deprecate-version"
class="block text-xs text-fg-subtle uppercase tracking-wider mb-1.5"
>
{{ $t('package.deprecation.modal.version') }}
</label>
<InputBase
id="deprecate-version"
v-model="deprecateVersion"
type="text"
name="deprecate-version"
:disabled="isSelectedVersionDeprecated"
class="w-full"
size="md"
:placeholder="$t('package.deprecation.modal.version_placeholder')"
/>
</div>
<div
v-if="deprecateError"
role="alert"
class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md"
>
{{ deprecateError }}
</div>
<button
type="button"
:disabled="isDeprecating || !deprecateMessage.trim() || isSelectedVersionDeprecated"
class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
@click="handleDeprecate"
>
{{
isDeprecating
? $t('package.deprecation.modal.deprecating')
: $t('package.deprecation.action')
}}
</button>
</div>
</Modal>
</template>
51 changes: 51 additions & 0 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,21 @@ const latestVersion = computed(() => {
return pkg.value.versions[latestTag] ?? null
})

/** True when the currently displayed version (or resolved version) is deprecated; used to hide deprecate button. */
const isCurrentVersionDeprecated = computed(() => {
if (displayVersion.value?.deprecated) return true
const ver = resolvedVersion.value
return !!(ver && pkg.value?.versions?.[ver]?.deprecated)
})

/** Version strings that are already deprecated; passed to DeprecatePackageModal to avoid extra fetch. */
const deprecatedVersions = computed(() => {
if (!pkg.value?.versions) return []
return Object.entries(pkg.value.versions)
.filter(([, metadata]) => !!metadata.deprecated)
.map(([version]) => version)
})

const deprecationNotice = computed(() => {
if (!displayVersion.value?.deprecated) return null

Expand All @@ -341,6 +356,17 @@ const deprecationNoticeMessage = useMarkdown(() => ({
text: deprecationNotice.value?.message ?? '',
}))

const { isConnected, npmUser } = useConnector()
const deprecateModal = useTemplateRef<{ open: () => void }>('deprecateModal')

const isPackageOwner = computed(() => {
const maintainers = pkg.value?.maintainers
const user = npmUser.value
if (!maintainers?.length || !user) return false
const userLower = user.toLowerCase()
return maintainers.some((m: { name?: string }) => (m.name ?? '').toLowerCase() === userLower)
})

const publishSecurityDowngrade = computed(() => {
const currentVersion = displayVersion.value?.version
if (!currentVersion) return null
Expand Down Expand Up @@ -989,6 +1015,21 @@ const showSkeleton = shallowRef(false)

<!-- Maintainers (with admin actions when connected) -->
<PackageMaintainers :package-name="pkg.name" :maintainers="pkg.maintainers" />

<!-- Deprecation (when connected as package owner; hidden when current version is already deprecated) -->
<div
v-if="isConnected && resolvedVersion && isPackageOwner && !isCurrentVersionDeprecated"
class="space-y-1"
>
<button
type="button"
class="flex items-center justify-center gap-1.5 w-full px-3 py-1.5 bg-bg-subtle rounded text-sm font-mono text-red-400 hover:text-red-500 transition-colors"
@click="deprecateModal?.open()"
>
<span class="i-carbon-warning-alt w-4 h-4 shrink-0" aria-hidden="true" />
{{ $t('package.deprecation.action') }}
</button>
</div>
Comment on lines +1019 to +1032
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the button needs to be visually aligned with the rest of the sidebar elements, and we should use ButtonBase with the Lucide icons as well.
I was playing around with it, so if you want to can just use this suggestion. Let me know what you think.

Suggested change
<!-- Deprecation (when connected as package owner; hidden when current version is already deprecated) -->
<div
v-if="isConnected && resolvedVersion && isPackageOwner && !isCurrentVersionDeprecated"
class="space-y-1"
>
<button
type="button"
class="flex items-center justify-center gap-1.5 w-full px-3 py-1.5 bg-bg-subtle rounded text-sm font-mono text-red-400 hover:text-red-500 transition-colors"
@click="deprecateModal?.open()"
>
<span class="i-carbon-warning-alt w-4 h-4 shrink-0" aria-hidden="true" />
{{ $t('package.deprecation.action') }}
</button>
</div>
<!-- Deprecation (when connected as package owner; hidden when current version is already deprecated) -->
<div
v-if="isConnected && resolvedVersion && isPackageOwner && !isCurrentVersionDeprecated"
class="pl-7"
>
<ButtonBase
type="button"
variant="secondary"
class="w-full text-red"
@click="deprecateModal?.open()"
classicon="i-lucide:triangle-alert text-red"
>
{{ $t('package.deprecation.action') }}
</ButtonBase>
</div>

</div>
</PackageSidebar>

Expand Down Expand Up @@ -1094,6 +1135,16 @@ const showSkeleton = shallowRef(false)
$t('common.go_back_home')
}}</LinkBase>
</div>
<ClientOnly>
<PackageDeprecatePackageModal
v-if="pkg"
ref="deprecateModal"
:package-name="pkg.name"
:version="resolvedVersion ?? ''"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if we don't get the version?

:is-already-deprecated="isCurrentVersionDeprecated"
:deprecated-versions="deprecatedVersions"
/>
</ClientOnly>
</main>
</template>

Expand Down
5 changes: 5 additions & 0 deletions cli/src/mock-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,11 @@ export class MockConnectorStateManager {
}
break
}
case 'package:deprecate': {
// Params: { pkg, message, version? } — PackageDeprecateParamsSchema
// Deprecation is a registry-side mutation; no local mock state to update.
break
}
}
}

Expand Down
Loading
Loading