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

const props = withDefaults(
defineProps<{
packageName: string
version?: string
}>(),
{ version: '' },
)

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

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

isDeprecating.value = true
deprecateError.value = null

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

const escapedMessage = message.replace(/"/g, '\\"')
const command = params.version
? `npm deprecate ${props.packageName}@${params.version} "${escapedMessage}"`
: `npm deprecate ${props.packageName} "${escapedMessage}"`

const operation = await addOperation({
type: 'package:deprecate',
params,
description: params.version
? `Deprecate ${props.packageName}@${params.version}`
: `Deprecate ${props.packageName}`,
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 = ref<InstanceType<typeof Modal> | undefined>()

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">
<!-- Success state -->
<div v-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-none focus-visible:ring-2 focus-visible:ring-fg/50"
@click="close"
>
{{ $t('common.close') }}
</button>
</div>

<!-- Form (only shown when connected; parent only opens modal when isConnected) -->
<div v-else class="space-y-4">
<div>
<label for="deprecate-message" class="block text-sm font-medium text-fg mb-1">
{{ $t('package.deprecation.modal.reason') }}
</label>
<textarea
id="deprecate-message"
v-model="deprecateMessage"
rows="3"
class="w-full px-3 py-2 font-mono text-sm bg-bg border border-border rounded-md text-fg placeholder:text-fg-muted focus:outline-none focus:ring-2 focus:ring-fg/50"
:placeholder="$t('package.deprecation.modal.reason_placeholder')"
/>
</div>
<div>
<label for="deprecate-version" class="block text-sm font-medium text-fg mb-1">
{{ $t('package.deprecation.modal.version') }}
</label>
<input
id="deprecate-version"
v-model="deprecateVersion"
type="text"
class="w-full px-3 py-2 font-mono text-sm bg-bg border border-border rounded-md text-fg placeholder:text-fg-muted focus:outline-none focus:ring-2 focus:ring-fg/50"
: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()"
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>
35 changes: 35 additions & 0 deletions app/pages/package/[...package].vue
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,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 sizeTooltip = computed(() => {
const chunks = [
displayVersion.value &&
Expand Down Expand Up @@ -1228,6 +1239,22 @@ onKeyStroke(
:peer-dependencies-meta="displayVersion.peerDependenciesMeta"
:optional-dependencies="displayVersion.optionalDependencies"
/>

<!-- Deprecation (when connected as package owner) -->
<div v-if="isConnected && resolvedVersion && isPackageOwner" class="space-y-1">
<button
type="button"
class="flex items-center justify-center w-full px-3 py-1.5 bg-bg-subtle rounded text-sm font-mono text-red-400 hover:text-red-500 transition-colors inline-flex items-center gap-1.5 w-full"
@click="deprecateModal?.open()"
>
<span class="i-carbon-warning-alt w-4 h-4 shrink-0" aria-hidden="true" />
{{
deprecationNotice
? $t('package.deprecation.action_change')
: $t('package.deprecation.action')
}}
</button>
</div>
</div>
</div>
</article>
Expand All @@ -1246,6 +1273,14 @@ onKeyStroke(
</p>
<NuxtLink to="/" class="btn">{{ $t('common.go_back_home') }}</NuxtLink>
</div>
<ClientOnly>
<PackageDeprecatePackageModal
v-if="pkg"
ref="deprecateModal"
:package-name="pkg.name"
:version="resolvedVersion ?? ''"
/>
</ClientOnly>
</main>
</template>

Expand Down
37 changes: 37 additions & 0 deletions cli/src/npm-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,3 +431,40 @@ export async function packageInit(
})
}
}

/**
* Deprecate a package or a specific version with a custom message.
* @param pkg Package name (e.g. "vue" or "@nuxt/kit")
* @param reason Deprecation message shown to users
* @param version Optional version to deprecate (e.g. "1.0.0"); if omitted, deprecates the whole package
* @param options.dryRun If true, passes --dry-run to npm (report what would be done without making changes)
* @param options.registry Registry URL (e.g. "https://registry.npmjs.org"); if set, passes --registry
*/
export async function packageDeprecate(
pkg: string,
reason: string,
version?: string,
otp?: string,
options?: { dryRun?: boolean; registry?: string },
): Promise<NpmExecResult> {
validatePackageName(pkg)

const reasonText = reason.trim()

if (!reasonText) {
throw new Error('Deprecation reason must not be empty')
}

const target = version ? `${pkg}@${version}` : pkg
const args = ['deprecate', target, reasonText]

if (options?.dryRun) {
args.push('--dry-run')
}

if (options?.registry?.trim()) {
args.push('--registry', options.registry.trim())
}

return execNpm(args, { otp })
}
16 changes: 16 additions & 0 deletions cli/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const OperationTypeSchema = v.picklist([
'owner:add',
'owner:rm',
'package:init',
'package:deprecate',
])

/**
Expand Down Expand Up @@ -240,6 +241,18 @@ export const PackageInitParamsSchema = v.object({
author: v.optional(UsernameSchema),
})

const PackageDeprecateParamsSchema = v.object({
pkg: PackageNameSchema,
message: v.pipe(
v.string(),
v.nonEmpty('Deprecation message is required'),
v.maxLength(500, 'Message is too long'),
),
version: v.optional(v.pipe(v.string(), v.nonEmpty())),
dryRun: v.optional(v.picklist(['true', 'false'], 'dryRun must be "true" or "false"')),
registry: v.optional(v.pipe(v.string(), v.minLength(1, 'Registry URL cannot be empty'))),
})

// ============================================================================
// Helper Functions
// ============================================================================
Expand Down Expand Up @@ -289,6 +302,9 @@ export function validateOperationParams(
case 'package:init':
v.parse(PackageInitParamsSchema, params)
break
case 'package:deprecate':
v.parse(PackageDeprecateParamsSchema, params)
break
}
}

Expand Down
9 changes: 9 additions & 0 deletions cli/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
ownerAdd,
ownerRemove,
packageInit,
packageDeprecate,
listUserPackages,
type NpmExecResult,
} from './npm-client.ts'
Expand Down Expand Up @@ -734,6 +735,14 @@ async function executeOperation(op: PendingOperation, otp?: string): Promise<Npm
return ownerRemove(params.user, params.pkg, otp)
case 'package:init':
return packageInit(params.name, params.author, otp)
case 'package:deprecate': {
const dryRun = params.dryRun === 'true'
const registry = params.registry?.trim() ?? undefined
return packageDeprecate(params.pkg, params.message, params.version, otp, {
dryRun: dryRun ?? undefined,
registry,
})
}
default:
return {
stdout: '',
Expand Down
1 change: 1 addition & 0 deletions cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type OperationType =
| 'owner:add'
| 'owner:rm'
| 'package:init'
| 'package:deprecate'

export type OperationStatus =
| 'pending'
Expand Down
15 changes: 14 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,22 @@
"navigation": "Package",
"copy_name": "Copy package name",
"deprecation": {
"action": "Deprecate",
"action_change": "Change deprecation",
"package": "This package has been deprecated.",
"version": "This version has been deprecated.",
"no_reason": "No reason provided"
"no_reason": "No reason provided",
"modal": {
"deprecating": "Deprecating",
"title": "Deprecate",
"title_version": "Deprecate Version",
"reason": "Reason",
"reason_placeholder": "e.g. Use package-x instead. This package is no longer maintained.",
"success": "Package deprecated",
"success_detail": "The package has been deprecated.",
"version": "Version",
"version_placeholder": "Leave empty to deprecate the whole package"
}
},
"replacement": {
"title": "You might not need this dependency.",
Expand Down
15 changes: 14 additions & 1 deletion i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,22 @@
"navigation": "包导航",
"copy_name": "拷贝包名",
"deprecation": {
"action": "废弃",
"action_change": "修改弃用",
"package": "这个包已经被弃用。",
"version": "这个版本已经被弃用。",
"no_reason": "没有提供原因"
"no_reason": "没有提供原因",
"modal": {
"deprecating": "废弃中",
"title": "废弃",
"title_version": "废弃版本",
"reason": "原因",
"reason_placeholder": "例如:使用 package-x 替代。这个包不再维护。",
"success": "包已废弃",
"success_detail": "这个包已废弃。",
"version": "版本",
"version_placeholder": "留空则废弃整个包"
}
},
"replacement": {
"title": "你可能不需要这个依赖。",
Expand Down
Loading
Loading