pi-jscpd 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -1
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/extension.ts +21 -1
- package/src/update-notice.ts +194 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@ published releases use [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2026-09-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Added a quiet, TUI-only update warning using one bounded, best-effort npm
|
|
15
|
+
metadata check, with offline and environment-variable opt-outs.
|
|
16
|
+
|
|
10
17
|
## [0.2.0] - 2026-09-06
|
|
11
18
|
|
|
12
19
|
### Added
|
|
@@ -116,7 +123,8 @@ published releases use [Semantic Versioning](https://semver.org/).
|
|
|
116
123
|
- Project paths, child output, reports, temporary directories, cancellation,
|
|
117
124
|
configuration trust, and lifecycle cleanup are bounded and fail open.
|
|
118
125
|
|
|
119
|
-
[Unreleased]: https://github.com/revazi/pi-jscpd/compare/v0.2.
|
|
126
|
+
[Unreleased]: https://github.com/revazi/pi-jscpd/compare/v0.2.1...HEAD
|
|
127
|
+
[0.2.1]: https://github.com/revazi/pi-jscpd/compare/v0.2.0...v0.2.1
|
|
120
128
|
[0.2.0]: https://github.com/revazi/pi-jscpd/compare/v0.1.1...v0.2.0
|
|
121
129
|
[0.1.1]: https://github.com/revazi/pi-jscpd/compare/v0.1.0...v0.1.1
|
|
122
130
|
[0.1.0]: https://github.com/revazi/pi-jscpd/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -32,6 +32,12 @@ Start Pi in your project and verify the setup:
|
|
|
32
32
|
If no compatible binary is available, the extension stays dormant and Pi
|
|
33
33
|
continues normally.
|
|
34
34
|
|
|
35
|
+
In TUI sessions, the extension performs one best-effort, metadata-only npm check
|
|
36
|
+
and shows a warning only when a newer `pi-jscpd` release is available. The check
|
|
37
|
+
is bounded to 1.5 seconds, never sends project data, and never downloads or
|
|
38
|
+
installs package content. Set `PI_JSCPD_DISABLE_UPDATE_NOTICE=1` (or run Pi
|
|
39
|
+
offline) to disable it.
|
|
40
|
+
|
|
35
41
|
## Usage
|
|
36
42
|
|
|
37
43
|
Run `/jscpd` to open the interactive overview. Opening it shows status only; it
|
package/package.json
CHANGED
package/src/extension.ts
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
} from "./status.js";
|
|
71
71
|
import { renderJscpdToolCall, renderJscpdToolResult } from "./tool-render.js";
|
|
72
72
|
import type { JscpdCommandExecutor, JscpdDispatchResult } from "./types.js";
|
|
73
|
+
import { createJscpdUpdateNoticeService, type JscpdUpdateNoticeService } from "./update-notice.js";
|
|
73
74
|
import { createJscpdVerificationService, type JscpdVerificationService } from "./verification.js";
|
|
74
75
|
|
|
75
76
|
type JscpdToolDefinition = ToolDefinition<typeof jscpdRunParams, JscpdDispatchResult>;
|
|
@@ -91,6 +92,7 @@ export interface JscpdExtensionOptions {
|
|
|
91
92
|
overlayLauncher?: JscpdOverlayLauncher;
|
|
92
93
|
verificationService?: JscpdVerificationService;
|
|
93
94
|
fallowCoexistenceService?: JscpdFallowCoexistenceService;
|
|
95
|
+
updateNoticeService?: JscpdUpdateNoticeService;
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
export function registerJscpdExtension(
|
|
@@ -117,6 +119,7 @@ export function registerJscpdExtension(
|
|
|
117
119
|
const configService = options.configService ?? createJscpdConfigService();
|
|
118
120
|
const fallowCoexistence =
|
|
119
121
|
options.fallowCoexistenceService ?? createJscpdFallowCoexistenceService();
|
|
122
|
+
const updateNotice = options.updateNoticeService ?? createJscpdUpdateNoticeService();
|
|
120
123
|
const startOwnedBaseline = (context: JscpdBaselineStartContext): void => {
|
|
121
124
|
const started = startBaselineQuietly(runtime, baselineService, context);
|
|
122
125
|
baselineSettlement = Promise.all([baselineSettlement, started]).then(() => undefined);
|
|
@@ -211,7 +214,10 @@ export function registerJscpdExtension(
|
|
|
211
214
|
);
|
|
212
215
|
|
|
213
216
|
pi.on("session_start", async (_event, ctx) => {
|
|
214
|
-
if (ctx.mode === "tui")
|
|
217
|
+
if (ctx.mode === "tui") {
|
|
218
|
+
installJscpdAutocompleteProvider(ctx.ui);
|
|
219
|
+
scheduleJscpdUpdateNotice(runtime, updateNotice, ctx);
|
|
220
|
+
}
|
|
215
221
|
runtime.runSync(scheduler.resetEffect);
|
|
216
222
|
fallowCoexistence.reset();
|
|
217
223
|
verificationService?.reset();
|
|
@@ -369,6 +375,20 @@ export function registerJscpdExtension(
|
|
|
369
375
|
});
|
|
370
376
|
}
|
|
371
377
|
|
|
378
|
+
function scheduleJscpdUpdateNotice(
|
|
379
|
+
runtime: JscpdEffectRuntime,
|
|
380
|
+
service: JscpdUpdateNoticeService,
|
|
381
|
+
context: ExtensionContext,
|
|
382
|
+
): void {
|
|
383
|
+
void runtime.runPromiseExit(
|
|
384
|
+
service.noticeEffect.pipe(
|
|
385
|
+
Effect.flatMap((message) =>
|
|
386
|
+
message ? Effect.sync(() => context.ui.notify(message, "warning")) : Effect.void,
|
|
387
|
+
),
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
372
392
|
function requestAutomaticCheck(
|
|
373
393
|
runtime: JscpdEffectRuntime,
|
|
374
394
|
pi: ExtensionAPI,
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { get } from "node:https";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
|
|
5
|
+
const PACKAGE_NAME = "pi-jscpd";
|
|
6
|
+
const PACKAGE_JSON_URL = new URL("../package.json", import.meta.url);
|
|
7
|
+
const NPM_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
8
|
+
const UPDATE_COMMAND = `pi update npm:${PACKAGE_NAME}`;
|
|
9
|
+
const DISABLE_UPDATE_ENV = "PI_JSCPD_DISABLE_UPDATE_NOTICE";
|
|
10
|
+
const UPDATE_CHECK_ENV = "PI_JSCPD_UPDATE_CHECK";
|
|
11
|
+
const UPDATE_CHECK_TIMEOUT_MS = 1_500;
|
|
12
|
+
const MAX_RESPONSE_BYTES = 16 * 1_024;
|
|
13
|
+
const VERSION_PATTERN =
|
|
14
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
15
|
+
|
|
16
|
+
interface JscpdUpdateNoticeOptions {
|
|
17
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
18
|
+
readonly currentVersionEffect?: Effect.Effect<string | undefined, unknown>;
|
|
19
|
+
readonly latestVersionEffect?: Effect.Effect<string | undefined, unknown>;
|
|
20
|
+
readonly timeoutMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface JscpdUpdateNoticeService {
|
|
24
|
+
/** Resolve at most one warning for this extension instance; failures stay silent. */
|
|
25
|
+
readonly noticeEffect: Effect.Effect<string | undefined>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Best-effort npm metadata check. It never downloads or installs package content. */
|
|
29
|
+
export function createJscpdUpdateNoticeService(
|
|
30
|
+
options: JscpdUpdateNoticeOptions = {},
|
|
31
|
+
): JscpdUpdateNoticeService {
|
|
32
|
+
const environment = options.environment ?? process.env;
|
|
33
|
+
let shown = false;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
noticeEffect: Effect.suspend(() => {
|
|
37
|
+
if (shown || isUpdateCheckDisabled(environment)) return Effect.succeed(undefined);
|
|
38
|
+
shown = true;
|
|
39
|
+
|
|
40
|
+
const currentVersion = recoverLookup(
|
|
41
|
+
options.currentVersionEffect ?? readCurrentVersionEffect(),
|
|
42
|
+
);
|
|
43
|
+
const latestVersion = recoverLookup(
|
|
44
|
+
options.latestVersionEffect ?? fetchLatestVersionEffect(),
|
|
45
|
+
).pipe(
|
|
46
|
+
Effect.timeoutTo({
|
|
47
|
+
duration: options.timeoutMs ?? UPDATE_CHECK_TIMEOUT_MS,
|
|
48
|
+
onSuccess: (version) => version,
|
|
49
|
+
onTimeout: () => undefined,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return currentVersion.pipe(
|
|
54
|
+
Effect.flatMap((current) =>
|
|
55
|
+
current
|
|
56
|
+
? latestVersion.pipe(Effect.map((latest) => buildUpdateNotice(current, latest)))
|
|
57
|
+
: Effect.succeed(undefined),
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readCurrentVersionEffect(): Effect.Effect<string | undefined, Error> {
|
|
65
|
+
return Effect.tryPromise({
|
|
66
|
+
try: () => readFile(PACKAGE_JSON_URL, "utf8"),
|
|
67
|
+
catch: () => new Error("Unable to read package metadata."),
|
|
68
|
+
}).pipe(Effect.map(parsePackageVersion));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parsePackageVersion(source: string): string | undefined {
|
|
72
|
+
try {
|
|
73
|
+
const value = JSON.parse(source) as { version?: unknown };
|
|
74
|
+
return typeof value.version === "string" && VERSION_PATTERN.test(value.version)
|
|
75
|
+
? value.version
|
|
76
|
+
: undefined;
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fetchLatestVersionEffect(): Effect.Effect<string | undefined, Error> {
|
|
83
|
+
return Effect.async((resume) => {
|
|
84
|
+
let settled = false;
|
|
85
|
+
const settle = (effect: Effect.Effect<string | undefined, Error>): void => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
resume(effect);
|
|
89
|
+
};
|
|
90
|
+
const request = get(NPM_LATEST_URL, { headers: { accept: "application/json" } }, (response) => {
|
|
91
|
+
if (response.statusCode !== 200) {
|
|
92
|
+
response.resume();
|
|
93
|
+
settle(Effect.succeed(undefined));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const declaredLength = Number(response.headers["content-length"]);
|
|
98
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
|
|
99
|
+
response.destroy();
|
|
100
|
+
settle(Effect.succeed(undefined));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const chunks: Uint8Array[] = [];
|
|
105
|
+
let total = 0;
|
|
106
|
+
response.on("data", (value: Buffer) => {
|
|
107
|
+
total += value.byteLength;
|
|
108
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
109
|
+
response.destroy();
|
|
110
|
+
settle(Effect.succeed(undefined));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
chunks.push(value);
|
|
114
|
+
});
|
|
115
|
+
response.once("end", () => {
|
|
116
|
+
settle(Effect.succeed(parseLatestVersionBytes(chunks, total)));
|
|
117
|
+
});
|
|
118
|
+
response.once("error", () => {
|
|
119
|
+
settle(Effect.fail(new Error("Unable to read npm package metadata.")));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
request.once("error", () => {
|
|
123
|
+
settle(Effect.fail(new Error("Unable to check npm package metadata.")));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return Effect.sync(() => {
|
|
127
|
+
settled = true;
|
|
128
|
+
request.destroy();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseLatestVersionBytes(chunks: readonly Uint8Array[], total: number): string | undefined {
|
|
134
|
+
try {
|
|
135
|
+
const bytes = new Uint8Array(total);
|
|
136
|
+
let offset = 0;
|
|
137
|
+
for (const chunk of chunks) {
|
|
138
|
+
bytes.set(chunk, offset);
|
|
139
|
+
offset += chunk.byteLength;
|
|
140
|
+
}
|
|
141
|
+
const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
142
|
+
const value = JSON.parse(source) as { version?: unknown };
|
|
143
|
+
return typeof value.version === "string" && VERSION_PATTERN.test(value.version)
|
|
144
|
+
? value.version
|
|
145
|
+
: undefined;
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function recoverLookup(
|
|
152
|
+
effect: Effect.Effect<string | undefined, unknown>,
|
|
153
|
+
): Effect.Effect<string | undefined> {
|
|
154
|
+
return effect.pipe(Effect.catchAll(() => Effect.succeed(undefined)));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildUpdateNotice(
|
|
158
|
+
currentVersion: string,
|
|
159
|
+
latestVersion: string | undefined,
|
|
160
|
+
): string | undefined {
|
|
161
|
+
if (!latestVersion || compareVersions(latestVersion, currentVersion) <= 0) return undefined;
|
|
162
|
+
return `${PACKAGE_NAME} ${latestVersion} is available (you have ${currentVersion}). Update: ${UPDATE_COMMAND}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function compareVersions(left: string, right: string): number {
|
|
166
|
+
const leftMatch = VERSION_PATTERN.exec(left);
|
|
167
|
+
const rightMatch = VERSION_PATTERN.exec(right);
|
|
168
|
+
if (!leftMatch || !rightMatch) return 0;
|
|
169
|
+
|
|
170
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
171
|
+
const difference = Number(leftMatch[index]) - Number(rightMatch[index]);
|
|
172
|
+
if (difference !== 0) return Math.sign(difference);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const leftPrerelease = left.includes("-");
|
|
176
|
+
const rightPrerelease = right.includes("-");
|
|
177
|
+
if (leftPrerelease === rightPrerelease) return 0;
|
|
178
|
+
return leftPrerelease ? -1 : 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isUpdateCheckDisabled(environment: Readonly<Record<string, string | undefined>>): boolean {
|
|
182
|
+
const offline = environment.PI_OFFLINE;
|
|
183
|
+
if (offline !== undefined && !isFalseLike(offline)) return true;
|
|
184
|
+
|
|
185
|
+
const disabled = environment[DISABLE_UPDATE_ENV];
|
|
186
|
+
if (disabled !== undefined) return !isFalseLike(disabled);
|
|
187
|
+
|
|
188
|
+
const enabled = environment[UPDATE_CHECK_ENV];
|
|
189
|
+
return enabled !== undefined && isFalseLike(enabled);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isFalseLike(value: string): boolean {
|
|
193
|
+
return ["0", "false", "off", "no"].includes(value.toLocaleLowerCase());
|
|
194
|
+
}
|