Skip to content
Open
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2231fd7
feat: add deprecate feature
eryue0220 Feb 4, 2026
f9d0df5
add i18n
eryue0220 Feb 4, 2026
36d03de
Merge branch 'main' into feat/resolve-40
eryue0220 Feb 4, 2026
bb37228
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 4, 2026
fb860dc
Merge branch 'main' into feat/resolve-40
eryue0220 Feb 4, 2026
58e112d
update
eryue0220 Feb 4, 2026
2159074
update
eryue0220 Feb 4, 2026
2530ba9
update
eryue0220 Feb 4, 2026
d80dd64
ut
eryue0220 Feb 4, 2026
0c221f5
Update app/components/Package/DeprecatePackageModal.vue
eryue0220 Feb 4, 2026
4264814
add ut
eryue0220 Feb 4, 2026
1b38e9e
Merge branch 'feat/resolve-40' of github.com:eryue0220/npmx.dev into …
eryue0220 Feb 4, 2026
8fa2405
chore: add lunaria az-AZ locale file
eryue0220 Feb 8, 2026
ea683a6
wip
eryue0220 Feb 8, 2026
fe0420c
cr
eryue0220 Feb 9, 2026
ec631d9
cr
eryue0220 Feb 9, 2026
62e8c99
cr
eryue0220 Feb 9, 2026
a8d922b
ci
eryue0220 Feb 9, 2026
cb12a0a
fix ut
eryue0220 Feb 9, 2026
baf0822
cr
eryue0220 Feb 9, 2026
5cc1b0a
add locale file
eryue0220 Feb 10, 2026
75f9c00
conflict & update cr
eryue0220 Feb 10, 2026
19c82f3
Merge locale file
eryue0220 Feb 12, 2026
5722aac
ci & conflict
eryue0220 Feb 12, 2026
cd70766
Merge remote-tracking branch 'origin/main' into feat/resolve-40
danielroe Feb 26, 2026
4b9a900
fix: use InputBase, update cli mock + avoid extra fetch
danielroe Feb 26, 2026
6e44c34
fix unocss checker
eryue0220 Apr 17, 2026
7f4ec8e
fix ci
eryue0220 Apr 17, 2026
caeaebd
fix ci
eryue0220 Apr 17, 2026
5457d9c
Merge branch 'main' into feat/resolve-40
eryue0220 Apr 20, 2026
588ddfc
Update app/pages/package/[[org]]/[name].vue
eryue0220 Apr 20, 2026
14e5cd8
update request param
eryue0220 Apr 20, 2026
2548539
Merge branch 'feat/resolve-40' of github.com:eryue0220/npmx.dev into …
eryue0220 Apr 20, 2026
1ddcb21
fix ci
eryue0220 Apr 20, 2026
ad353f3
Merge branch 'main' into feat/resolve-40
eryue0220 Apr 20, 2026
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 +163 to +169
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 thread
eryue0220 marked this conversation as resolved.
</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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Then it will deprecate the whole package.

: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