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
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
270 changes: 270 additions & 0 deletions app/components/Package/DeprecatePackageModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
<script setup lang="ts">
import type { NewOperation } from '~/composables/useConnector'
import { PackageDeprecateParamsSchema, safeParse } from '~~/cli/src/schemas'

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}`,
)

/** True when the user has entered a version in the form that is already deprecated. */
const isSelectedVersionDeprecated = computed(() => {
const v = deprecateVersion.value.trim()
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 = safeParse(PackageDeprecateParamsSchema, params)
if (!parsed.success) {
deprecateError.value = parsed.error
return
}

isDeprecating.value = true
deprecateError.value = null

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

const operation = await addOperation({
type: 'package:deprecate',
params: {
pkg: parsed.data.pkg,
message: parsed.data.message,
...(parsed.data.version && { version: parsed.data.version }),
},
description: parsed.data.version
? `Deprecate ${parsed.data.pkg}@${parsed.data.version}`
: `Deprecate ${parsed.data.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 {
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 +153 to +159
Copy link
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="medium"
: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 @@ -375,6 +375,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 @@ -396,6 +411,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 @@ -1496,6 +1522,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>
</div>
</PackageSidebar>
</article>
Expand All @@ -1516,6 +1557,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 ?? ''"
: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