Skip to content

Commit 2231fd7

Browse files
committed
feat: add deprecate feature
1 parent 3b7c027 commit 2231fd7

File tree

8 files changed

+292
-2
lines changed

8 files changed

+292
-2
lines changed
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
<script setup lang="ts">
2+
import type { NewOperation } from '~/composables/useConnector'
3+
4+
const props = withDefaults(
5+
defineProps<{
6+
packageName: string
7+
version?: string
8+
}>(),
9+
{ version: '' },
10+
)
11+
12+
const { t } = useI18n()
13+
const { isConnected, state, addOperation, approveOperation, executeOperations, refreshState } =
14+
useConnector()
15+
16+
const deprecateMessage = ref('')
17+
const deprecateVersion = ref(props.version)
18+
const isDeprecating = shallowRef(false)
19+
const deprecateSuccess = shallowRef(false)
20+
const deprecateError = shallowRef<string | null>(null)
21+
22+
const connectorModal = useModal('connector-modal')
23+
24+
const modalTitle = computed(() =>
25+
deprecateVersion.value
26+
? `${$t('package.deprecation.modal.title')} ${props.packageName}@${deprecateVersion.value}`
27+
: `${$t('package.deprecation.modal.title')} ${props.packageName}`,
28+
)
29+
30+
async function handleDeprecate() {
31+
const message = deprecateMessage.value.trim()
32+
if (!message || !isConnected.value) return
33+
34+
isDeprecating.value = true
35+
deprecateError.value = null
36+
37+
try {
38+
const params: Record<string, string> = {
39+
pkg: props.packageName,
40+
message,
41+
}
42+
if (deprecateVersion.value.trim()) {
43+
params.version = deprecateVersion.value.trim()
44+
}
45+
46+
const command = params.version
47+
? `npm deprecate ${props.packageName}@${params.version} "${message}"`
48+
: `npm deprecate ${props.packageName} "${message}"`
49+
50+
const operation = await addOperation({
51+
type: 'package:deprecate',
52+
params,
53+
description: params.version
54+
? `Deprecate ${props.packageName}@${params.version}`
55+
: `Deprecate ${props.packageName}`,
56+
command,
57+
} as NewOperation)
58+
59+
if (!operation) {
60+
throw new Error('Failed to create operation')
61+
}
62+
63+
await approveOperation(operation.id)
64+
await executeOperations()
65+
await refreshState()
66+
67+
const completedOp = state.value.operations.find(op => op.id === operation.id)
68+
if (completedOp?.status === 'completed') {
69+
deprecateSuccess.value = true
70+
} else if (completedOp?.status === 'failed') {
71+
if (completedOp.result?.requiresOtp) {
72+
close()
73+
connectorModal.open()
74+
} else {
75+
deprecateError.value = completedOp.result?.stderr || t('deprecate.modal.failed')
76+
}
77+
} else {
78+
close()
79+
connectorModal.open()
80+
}
81+
} catch (err) {
82+
deprecateError.value = err instanceof Error ? err.message : t('deprecate.modal.failed')
83+
} finally {
84+
isDeprecating.value = false
85+
}
86+
}
87+
88+
const dialogRef = ref<HTMLDialogElement>()
89+
90+
function open() {
91+
deprecateError.value = null
92+
deprecateSuccess.value = false
93+
deprecateMessage.value = ''
94+
deprecateVersion.value = props.version ?? ''
95+
dialogRef.value?.showModal()
96+
}
97+
98+
function close() {
99+
dialogRef.value?.close()
100+
}
101+
102+
defineExpose({ open, close })
103+
</script>
104+
105+
<template>
106+
<Modal ref="dialogRef" :modal-title="modalTitle" id="deprecate-package-modal" class="max-w-md">
107+
<!-- Success state -->
108+
<div v-if="deprecateSuccess" class="space-y-4">
109+
<div
110+
class="flex items-center gap-3 p-4 bg-green-500/10 border border-green-500/20 rounded-lg"
111+
>
112+
<span class="i-carbon-checkmark-filled text-green-500 w-6 h-6" aria-hidden="true" />
113+
<div>
114+
<p class="font-mono text-sm text-fg">{{ $t('package.deprecation.modal.success') }}</p>
115+
<p class="text-xs text-fg-muted">
116+
{{ $t('package.deprecation.modal.success_detail') }}
117+
</p>
118+
</div>
119+
</div>
120+
<button
121+
type="button"
122+
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"
123+
@click="close"
124+
>
125+
{{ $t('common.close') }}
126+
</button>
127+
</div>
128+
129+
<!-- Form (only shown when connected; parent only opens modal when isConnected) -->
130+
<div v-else class="space-y-4">
131+
<div>
132+
<label for="deprecate-message" class="block text-sm font-medium text-fg mb-1">
133+
{{ $t('package.deprecation.modal.reason') }}
134+
</label>
135+
<textarea
136+
id="deprecate-message"
137+
v-model="deprecateMessage"
138+
rows="3"
139+
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"
140+
:placeholder="$t('package.deprecation.modal.reason_placeholder')"
141+
/>
142+
</div>
143+
<div>
144+
<label for="deprecate-version" class="block text-sm font-medium text-fg mb-1">
145+
{{ $t('package.deprecation.modal.version') }}
146+
</label>
147+
<input
148+
id="deprecate-version"
149+
v-model="deprecateVersion"
150+
type="text"
151+
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"
152+
:placeholder="$t('package.deprecation.modal.version_placeholder')"
153+
/>
154+
</div>
155+
<div
156+
v-if="deprecateError"
157+
role="alert"
158+
class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md"
159+
>
160+
{{ deprecateError }}
161+
</div>
162+
<button
163+
type="button"
164+
:disabled="isDeprecating || !deprecateMessage.trim()"
165+
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"
166+
@click="handleDeprecate"
167+
>
168+
{{
169+
isDeprecating
170+
? $t('package.deprecation.modal.deprecating')
171+
: $t('package.deprecation.action')
172+
}}
173+
</button>
174+
</div>
175+
</Modal>
176+
</template>

app/pages/package/[...package].vue

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,17 @@ const deprecationNoticeMessage = useMarkdown(() => ({
187187
text: deprecationNotice.value?.message ?? '',
188188
}))
189189
190+
const { isConnected, npmUser } = useConnector()
191+
const deprecateModal = useTemplateRef<{ open: () => void }>('deprecateModal')
192+
193+
const isPackageOwner = computed(() => {
194+
const maintainers = pkg.value?.maintainers
195+
const user = npmUser.value
196+
if (!maintainers?.length || !user) return false
197+
const userLower = user.toLowerCase()
198+
return maintainers.some((m: { name?: string }) => (m.name ?? '').toLowerCase() === userLower)
199+
})
200+
190201
const sizeTooltip = computed(() => {
191202
const chunks = [
192203
displayVersion.value &&
@@ -1135,6 +1146,22 @@ defineOgImageComponent('Package', {
11351146
:peer-dependencies-meta="displayVersion.peerDependenciesMeta"
11361147
:optional-dependencies="displayVersion.optionalDependencies"
11371148
/>
1149+
1150+
<!-- Deprecation (when connected as package owner) -->
1151+
<div v-if="isConnected && resolvedVersion && isPackageOwner" class="space-y-1">
1152+
<button
1153+
type="button"
1154+
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"
1155+
@click="deprecateModal?.open()"
1156+
>
1157+
<span class="i-carbon-warning-alt w-4 h-4 shrink-0" aria-hidden="true" />
1158+
{{
1159+
deprecationNotice
1160+
? $t('package.deprecation.action_change')
1161+
: $t('package.deprecation.action')
1162+
}}
1163+
</button>
1164+
</div>
11381165
</div>
11391166
</div>
11401167
</article>
@@ -1153,6 +1180,14 @@ defineOgImageComponent('Package', {
11531180
</p>
11541181
<NuxtLink to="/" class="btn">{{ $t('common.go_back_home') }}</NuxtLink>
11551182
</div>
1183+
<ClientOnly>
1184+
<PackageDeprecatePackageModal
1185+
v-if="pkg"
1186+
ref="deprecateModal"
1187+
:package-name="pkg.name"
1188+
:version="resolvedVersion ?? ''"
1189+
/>
1190+
</ClientOnly>
11561191
</main>
11571192
</template>
11581193

cli/src/npm-client.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,3 +428,30 @@ export async function packageInit(
428428
})
429429
}
430430
}
431+
432+
/**
433+
* Deprecate a package or a specific version with a custom message.
434+
* @param pkg Package name (e.g. "vue" or "@nuxt/kit")
435+
* @param reason Deprecation message shown to users
436+
* @param version Optional version to deprecate (e.g. "1.0.0"); if omitted, deprecates the whole package
437+
* @param options.dryRun If true, passes --dry-run to npm (report what would be done without making changes)
438+
* @param options.registry Registry URL (e.g. "https://registry.npmjs.org"); if set, passes --registry
439+
*/
440+
export async function packageDeprecate(
441+
pkg: string,
442+
reason: string,
443+
version?: string,
444+
otp?: string,
445+
options?: { dryRun?: boolean; registry?: string },
446+
): Promise<NpmExecResult> {
447+
validatePackageName(pkg)
448+
const target = version ? `${pkg}@${version}` : pkg
449+
const args = ['deprecate', target, reason]
450+
if (options?.dryRun) {
451+
args.push('--dry-run')
452+
}
453+
if (options?.registry?.trim()) {
454+
args.push('--registry', options.registry.trim())
455+
}
456+
return execNpm(args, { otp })
457+
}

cli/src/schemas.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export const OperationTypeSchema = v.picklist([
109109
'owner:add',
110110
'owner:rm',
111111
'package:init',
112+
'package:deprecate',
112113
])
113114

114115
/**
@@ -240,6 +241,18 @@ export const PackageInitParamsSchema = v.object({
240241
author: v.optional(UsernameSchema),
241242
})
242243

244+
const PackageDeprecateParamsSchema = v.object({
245+
pkg: PackageNameSchema,
246+
message: v.pipe(
247+
v.string(),
248+
v.nonEmpty('Deprecation message is required'),
249+
v.maxLength(500, 'Message is too long'),
250+
),
251+
version: v.optional(v.pipe(v.string(), v.nonEmpty())),
252+
dryRun: v.optional(v.picklist(['true', 'false'], 'dryRun must be "true" or "false"')),
253+
registry: v.optional(v.pipe(v.string(), v.minLength(1, 'Registry URL cannot be empty'))),
254+
})
255+
243256
// ============================================================================
244257
// Helper Functions
245258
// ============================================================================
@@ -289,6 +302,9 @@ export function validateOperationParams(
289302
case 'package:init':
290303
v.parse(PackageInitParamsSchema, params)
291304
break
305+
case 'package:deprecate':
306+
v.parse(PackageDeprecateParamsSchema, params)
307+
break
292308
}
293309
}
294310

cli/src/server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
ownerAdd,
2424
ownerRemove,
2525
packageInit,
26+
packageDeprecate,
2627
listUserPackages,
2728
type NpmExecResult,
2829
} from './npm-client.ts'
@@ -734,6 +735,14 @@ async function executeOperation(op: PendingOperation, otp?: string): Promise<Npm
734735
return ownerRemove(params.user, params.pkg, otp)
735736
case 'package:init':
736737
return packageInit(params.name, params.author, otp)
738+
case 'package:deprecate': {
739+
const dryRun = params.dryRun === 'true'
740+
const registry = params.registry?.trim() ?? undefined
741+
return packageDeprecate(params.pkg, params.message, params.version, otp, {
742+
dryRun: dryRun ?? undefined,
743+
registry,
744+
})
745+
}
737746
default:
738747
return {
739748
stdout: '',

cli/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type OperationType =
2424
| 'owner:add'
2525
| 'owner:rm'
2626
| 'package:init'
27+
| 'package:deprecate'
2728

2829
export type OperationStatus =
2930
| 'pending'

lunaria/files/en-GB.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,22 @@
128128
"navigation": "Package",
129129
"copy_name": "Copy package name",
130130
"deprecation": {
131+
"action": "Deprecate",
132+
"action_change": "Change deprecation",
131133
"package": "This package has been deprecated.",
132134
"version": "This version has been deprecated.",
133-
"no_reason": "No reason provided"
135+
"no_reason": "No reason provided",
136+
"modal": {
137+
"deprecating": "Deprecating",
138+
"title": "Deprecate",
139+
"title_version": "Deprecate Version",
140+
"reason": "Reason",
141+
"reason_placeholder": "e.g. Use package-x instead. This package is no longer maintained.",
142+
"success": "Package deprecated",
143+
"success_detail": "The package has been deprecated.",
144+
"version": "Version",
145+
"version_placeholder": "Leave empty to deprecate the whole package"
146+
}
134147
},
135148
"replacement": {
136149
"title": "You might not need this dependency.",

lunaria/files/en-US.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,22 @@
128128
"navigation": "Package",
129129
"copy_name": "Copy package name",
130130
"deprecation": {
131+
"action": "Deprecate",
132+
"action_change": "Change deprecation",
131133
"package": "This package has been deprecated.",
132134
"version": "This version has been deprecated.",
133-
"no_reason": "No reason provided"
135+
"no_reason": "No reason provided",
136+
"modal": {
137+
"deprecating": "Deprecating",
138+
"title": "Deprecate",
139+
"title_version": "Deprecate Version",
140+
"reason": "Reason",
141+
"reason_placeholder": "e.g. Use package-x instead. This package is no longer maintained.",
142+
"success": "Package deprecated",
143+
"success_detail": "The package has been deprecated.",
144+
"version": "Version",
145+
"version_placeholder": "Leave empty to deprecate the whole package"
146+
}
134147
},
135148
"replacement": {
136149
"title": "You might not need this dependency.",

0 commit comments

Comments
 (0)