@danypops/pi-packed 0.19.7 → 0.19.10
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/dist/client.d.ts +109 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1 -0
- package/dist/protocol.d.ts +221 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +1 -0
- package/extension/src/{permission.ts → approval/permission.ts} +1 -1
- package/extension/src/index.ts +1 -1
- package/extension/src/packed.ts +2 -2
- package/extension/src/{discover.ts → tabs/discover.ts} +4 -4
- package/extension/src/{resource-config.ts → tabs/resource-config.ts} +4 -4
- package/extension/src/{security-tui.ts → tabs/security-tui.ts} +3 -3
- package/extension/src/tool-output.ts +1 -1
- package/extension/src/tools.ts +2 -2
- package/extension/src/tui.ts +4 -4
- package/package.json +31 -8
- package/service/schema/pi-setup-v1.schema.json +70 -0
- package/service/setup/danypops-ecosystem.pi-setup.json +15 -0
- package/service/src/adoption/advisories.ts +268 -0
- package/service/src/adoption/check.ts +872 -0
- package/service/src/adoption/commit-freshness.ts +167 -0
- package/service/src/adoption/doctor.ts +135 -0
- package/service/src/adoption/install-validation.ts +187 -0
- package/service/src/adoption/pack.ts +291 -0
- package/service/src/adoption/score.ts +466 -0
- package/service/src/adoption/smoke-child.ts +113 -0
- package/service/src/adoption/smoke.ts +282 -0
- package/service/src/cli/cli.ts +926 -0
- package/service/src/daemon/cleanup.ts +76 -0
- package/service/src/daemon/client.ts +412 -0
- package/service/src/daemon/daemon-service.ts +249 -0
- package/service/src/daemon/daemon.ts +110 -0
- package/service/src/daemon/service.ts +664 -0
- package/service/src/daemon/watcher.ts +92 -0
- package/service/src/index/build-index.ts +256 -0
- package/service/src/packages/catalog.ts +61 -0
- package/service/src/packages/db.ts +224 -0
- package/service/src/packages/install.ts +60 -0
- package/service/src/packages/installed.ts +123 -0
- package/service/src/packages/package.ts +141 -0
- package/service/src/packages/resources.ts +203 -0
- package/service/src/pi/pi-version.ts +171 -0
- package/service/src/public/atomic-json.ts +32 -0
- package/service/src/public/client.ts +277 -0
- package/service/src/public/protocol.ts +169 -0
- package/service/src/publish/publish.ts +855 -0
- package/service/src/registry/registry.ts +246 -0
- package/service/src/security/security.ts +128 -0
- package/service/src/self-update/self-update.ts +148 -0
- package/service/src/setup/setup.ts +761 -0
- package/service/src/shared/atomic-json.ts +33 -0
- package/service/src/shared/cache.ts +21 -0
- package/service/src/shared/constants.ts +73 -0
- package/service/src/shared/log.ts +21 -0
- package/service/src/shared/paths.ts +88 -0
- package/service/src/shared/state.ts +15 -0
- package/service/src/shared/version.ts +46 -0
- package/service/test/advisories.test.ts +287 -0
- package/service/test/check.test.ts +368 -0
- package/service/test/cleanup.test.ts +220 -0
- package/service/test/cli.test.ts +1303 -0
- package/service/test/core.test.ts +181 -0
- package/service/test/daemon-kit-migration.test.ts +181 -0
- package/service/test/daemon-service.test.ts +238 -0
- package/service/test/db.test.ts +178 -0
- package/service/test/doctor.test.ts +234 -0
- package/service/test/domain.test.ts +291 -0
- package/service/test/fixtures/install-validation/broken-package/extension/index.ts +3 -0
- package/service/test/fixtures/install-validation/broken-package/package.json +8 -0
- package/service/test/fixtures/install-validation/healthy-package/extension/index.ts +3 -0
- package/service/test/fixtures/install-validation/healthy-package/package.json +8 -0
- package/service/test/fixtures/install-validation/no-manifest-package/package.json +5 -0
- package/service/test/index.test.ts +353 -0
- package/service/test/install-validation.test.ts +114 -0
- package/service/test/install.test.ts +113 -0
- package/service/test/log.test.ts +42 -0
- package/service/test/pack-score.test.ts +513 -0
- package/service/test/pi-version.test.ts +318 -0
- package/service/test/public-boundary.test.ts +54 -0
- package/service/test/public-client.test.ts +127 -0
- package/service/test/public-consumer.ts +8 -0
- package/service/test/publish.test.ts +333 -0
- package/service/test/registry-contract.test.ts +148 -0
- package/service/test/resources.test.ts +255 -0
- package/service/test/security.test.ts +89 -0
- package/service/test/self-update.test.ts +257 -0
- package/service/test/service.test.ts +555 -0
- package/service/test/setup.test.ts +375 -0
- package/service/test/smoke.test.ts +118 -0
- package/service/test/version.test.ts +37 -0
- package/service/tsconfig.consumer.json +13 -0
- package/service/tsconfig.public.json +12 -0
- /package/extension/src/{reload.ts → approval/reload.ts} +0 -0
- /package/extension/src/{discover-model.ts → tabs/discover-model.ts} +0 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** install.ts — driven adapter: pi CLI mutations via Bun.spawn. */
|
|
2
|
+
|
|
3
|
+
import { HeadlessInstallValidator, type InstallValidator } from "../adoption/install-validation.ts";
|
|
4
|
+
import { defaultPiHome, isPinnedNpmSource, readResolvedVersion } from "./installed.ts";
|
|
5
|
+
import type { Installer, UpdateOutcome } from "./package.ts";
|
|
6
|
+
|
|
7
|
+
/** Bare npm package name (for `packed remove`). */
|
|
8
|
+
export const NAME_RE = /^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/;
|
|
9
|
+
|
|
10
|
+
export function defaultPiBin(): string {
|
|
11
|
+
return process.env.PI_PACKED_PI_BIN ?? process.env.PI_BIN ?? "pi";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ExecInstaller implements Installer {
|
|
15
|
+
constructor(
|
|
16
|
+
private bin = defaultPiBin(),
|
|
17
|
+
private piHome = defaultPiHome(),
|
|
18
|
+
private validator: InstallValidator = new HeadlessInstallValidator(),
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
private async run(args: string[]): Promise<string> {
|
|
22
|
+
const proc = Bun.spawn([this.bin, ...args], { stdout: "pipe", stderr: "pipe" });
|
|
23
|
+
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
24
|
+
const out = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
|
25
|
+
const code = await proc.exited;
|
|
26
|
+
if (code !== 0) throw new Error(out || `exit ${code}`);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async install(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string> {
|
|
31
|
+
const validation = await this.validator.validate(source);
|
|
32
|
+
if (!validation.ok) {
|
|
33
|
+
const detail = validation.extensions
|
|
34
|
+
.filter((extension) => !extension.ok)
|
|
35
|
+
.map((extension) => `${extension.path}: ${extension.message}`)
|
|
36
|
+
.join("; ");
|
|
37
|
+
throw new Error(`install refused -- ${detail || validation.message || "extension failed a headless load check"}`);
|
|
38
|
+
}
|
|
39
|
+
return this.run(["install", ...(options?.local ? ["-l"] : []), source]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
remove(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string> {
|
|
43
|
+
return this.run(["remove", ...(options?.local ? ["-l"] : []), source]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async update(source: string, _options?: { approved?: boolean; local?: boolean }): Promise<UpdateOutcome> {
|
|
47
|
+
const pinned = isPinnedNpmSource(source);
|
|
48
|
+
const previousVersion = readResolvedVersion(this.piHome, source);
|
|
49
|
+
const output = await this.run(["update", "--extension", source]);
|
|
50
|
+
const currentVersion = readResolvedVersion(this.piHome, source);
|
|
51
|
+
// Only trust a "nothing changed" conclusion when we actually read a
|
|
52
|
+
// real version both before and after (npm source, resolvable in
|
|
53
|
+
// node_modules). Otherwise (git:/https: sources, or an unreadable
|
|
54
|
+
// node_modules entry) fall back to the traditional "assume it may
|
|
55
|
+
// have changed" signal instead of falsely claiming it didn't.
|
|
56
|
+
const knowsBoth = previousVersion !== undefined && currentVersion !== undefined;
|
|
57
|
+
const changed = !knowsBoth || previousVersion !== currentVersion;
|
|
58
|
+
return { output, reloadRequired: changed, alreadyUpToDate: !changed, pinned, previousVersion, currentVersion };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** installed.ts — pi's settings.json is the source of truth for what is
|
|
2
|
+
* installed; node_modules supplies versions for unpinned sources. */
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { ENV, SETTINGS_FILE } from "../shared/constants.ts";
|
|
7
|
+
import type { InstalledPkg } from "./package.ts";
|
|
8
|
+
|
|
9
|
+
export function splitNpmSource(spec: string): [name: string, version: string] {
|
|
10
|
+
const i = spec.lastIndexOf("@");
|
|
11
|
+
if (i <= 0) return [spec, ""];
|
|
12
|
+
return [spec.slice(0, i), spec.slice(i + 1)];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function extractSource(entry: unknown): string {
|
|
16
|
+
if (typeof entry === "string") return entry;
|
|
17
|
+
if (entry && typeof entry === "object") {
|
|
18
|
+
const s = (entry as Record<string, unknown>).source;
|
|
19
|
+
if (typeof s === "string") return s;
|
|
20
|
+
}
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function nodeModulesVersion(piHome: string, name: string): string | undefined {
|
|
25
|
+
try {
|
|
26
|
+
const pkg = JSON.parse(readFileSync(join(piHome, "npm", "node_modules", name, "package.json"), "utf8"));
|
|
27
|
+
return typeof pkg.version === "string" ? pkg.version : undefined;
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True when a configured npm: source pins an exact version, e.g.
|
|
35
|
+
* "npm:@scope/pkg@1.2.3" vs. the floating "npm:@scope/pkg". `pi update`
|
|
36
|
+
* intentionally leaves pinned sources unchanged (see readInstalledPackages)
|
|
37
|
+
* but still exits 0 and prints "Updated <source>" either way -- callers
|
|
38
|
+
* must not treat that text as proof anything changed.
|
|
39
|
+
*/
|
|
40
|
+
export function isPinnedNpmSource(source: string): boolean {
|
|
41
|
+
if (!source.startsWith("npm:")) return false;
|
|
42
|
+
const [, pinned] = splitNpmSource(source.slice(4));
|
|
43
|
+
return pinned !== "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The bare npm package name for a configured npm: source, pinned or not.
|
|
47
|
+
* undefined for git:/https: sources -- there is no npm-registry name to read. */
|
|
48
|
+
export function npmPackageName(source: string): string | undefined {
|
|
49
|
+
if (!source.startsWith("npm:")) return undefined;
|
|
50
|
+
const [name] = splitNpmSource(source.slice(4));
|
|
51
|
+
return name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reads a single npm package's real on-disk resolved version, regardless of
|
|
55
|
+
* whether its configured source is pinned -- ground truth for detecting
|
|
56
|
+
* whether an update actually changed anything. undefined for non-npm
|
|
57
|
+
* sources or when node_modules has no matching package.json to read. */
|
|
58
|
+
export function readResolvedVersion(piHome: string, source: string): string | undefined {
|
|
59
|
+
const name = npmPackageName(source);
|
|
60
|
+
return name ? nodeModulesVersion(piHome, name) : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readResolvedIntegrity(piHome: string, source: string): string | undefined {
|
|
64
|
+
const name = npmPackageName(source);
|
|
65
|
+
if (!name) return undefined;
|
|
66
|
+
try {
|
|
67
|
+
const lock = JSON.parse(readFileSync(join(piHome, "npm", "package-lock.json"), "utf8")) as {
|
|
68
|
+
packages?: Record<string, { integrity?: unknown }>;
|
|
69
|
+
};
|
|
70
|
+
const integrity = lock.packages?.[`node_modules/${name}`]?.integrity;
|
|
71
|
+
return typeof integrity === "string" ? integrity : undefined;
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function readPackageDeclarations(piHome: string): string[] {
|
|
78
|
+
let settings: { packages?: unknown[] };
|
|
79
|
+
try {
|
|
80
|
+
settings = JSON.parse(readFileSync(join(piHome, SETTINGS_FILE), "utf8"));
|
|
81
|
+
} catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
return (settings.packages ?? [])
|
|
85
|
+
.map(extractSource)
|
|
86
|
+
.filter((source) => source.length > 0)
|
|
87
|
+
.slice(0, 500);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function readInstalledPackages(piHome: string): InstalledPkg[] {
|
|
91
|
+
const out: InstalledPkg[] = [];
|
|
92
|
+
for (const source of readPackageDeclarations(piHome)) {
|
|
93
|
+
if (!source.startsWith("npm:")) continue;
|
|
94
|
+
const [name, pinned] = splitNpmSource(source.slice(4));
|
|
95
|
+
out.push({
|
|
96
|
+
name,
|
|
97
|
+
pinned: pinned || undefined,
|
|
98
|
+
installed: pinned ? undefined : nodeModulesVersion(piHome, name),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Global scope alone is invisible to a project's own .pi/settings.json pin --
|
|
106
|
+
* confirmed live: a project pinned a stale major version of a since-split
|
|
107
|
+
* package, and nothing in packed's own drift detection ever saw it. Merges
|
|
108
|
+
* global and (when given) project-scoped declarations, tagging each so a
|
|
109
|
+
* name pinned differently in each scope is reported twice, not silently
|
|
110
|
+
* deduped into one.
|
|
111
|
+
*/
|
|
112
|
+
export function readInstalledPackagesAcrossScopes(piHome: string, projectRoot?: string): InstalledPkg[] {
|
|
113
|
+
const global = readInstalledPackages(piHome).map((pkg) => ({ ...pkg, scope: "global" as const }));
|
|
114
|
+
if (!projectRoot) return global;
|
|
115
|
+
const project = readInstalledPackages(join(projectRoot, ".pi")).map((pkg) => ({ ...pkg, scope: "project" as const }));
|
|
116
|
+
return [...global, ...project];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function defaultPiHome(): string {
|
|
120
|
+
const envHome = process.env[ENV.PI_HOME];
|
|
121
|
+
if (envHome) return envHome;
|
|
122
|
+
return join(homedir(), ".pi", "agent");
|
|
123
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package.ts — the Pkg entity and its two driven ports (Registry, Installer)
|
|
3
|
+
* that decouple callers (the HTTP service, the CLI, the watcher, tests) from
|
|
4
|
+
* concrete implementations (registry/registry.ts talks to npm; install.ts
|
|
5
|
+
* shells out to pi).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type PackageVerification = "keyword-only" | "manifest" | "conventional";
|
|
9
|
+
|
|
10
|
+
export interface PackageEvidence {
|
|
11
|
+
shape: PackageVerification;
|
|
12
|
+
verified: boolean;
|
|
13
|
+
evidence: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PublicationEvidence {
|
|
17
|
+
integrity?: string;
|
|
18
|
+
provenanceUrl?: string;
|
|
19
|
+
trustedPublisher: "verified" | "not-verified" | "unknown";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DownloadObservations {
|
|
23
|
+
weekly?: number;
|
|
24
|
+
monthly?: number;
|
|
25
|
+
observedAt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Pkg {
|
|
29
|
+
name: string;
|
|
30
|
+
version: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
date?: string;
|
|
33
|
+
packageEvidence?: PackageEvidence;
|
|
34
|
+
publication?: PublicationEvidence;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PkgInfo {
|
|
38
|
+
name: string;
|
|
39
|
+
version: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
homepage?: string;
|
|
42
|
+
repository?: string;
|
|
43
|
+
/** Monorepo subdirectory the package lives in, from repository.directory
|
|
44
|
+
* -- scopes a real commit-history lookup to the package's own path. */
|
|
45
|
+
repositoryDirectory?: string;
|
|
46
|
+
license?: string;
|
|
47
|
+
keywords?: string[];
|
|
48
|
+
pi?: Record<string, unknown>;
|
|
49
|
+
/** Declared npm lifecycle scripts (preinstall/install/postinstall/etc.) --
|
|
50
|
+
* these execute on install, a top supply-chain attack vector. Presence is
|
|
51
|
+
* surfaced as trust-dimension evidence, never treated as a failure. */
|
|
52
|
+
scripts?: Record<string, string>;
|
|
53
|
+
modified?: string;
|
|
54
|
+
unpackedSize?: number;
|
|
55
|
+
readme?: string;
|
|
56
|
+
readmeAvailable?: boolean;
|
|
57
|
+
bugs?: string;
|
|
58
|
+
peerDependencies?: Record<string, string>;
|
|
59
|
+
packageEvidence?: PackageEvidence;
|
|
60
|
+
publication?: PublicationEvidence;
|
|
61
|
+
downloads?: DownloadObservations;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SearchPage {
|
|
65
|
+
results: Pkg[];
|
|
66
|
+
total: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Driven port: package metadata source (npm registry, or the daemon proxy). */
|
|
70
|
+
export interface Registry {
|
|
71
|
+
search(query: string, limit: number): Promise<SearchPage>;
|
|
72
|
+
searchPage(query: string, from: number, size: number): Promise<SearchPage>;
|
|
73
|
+
searchAll(query: string): Promise<Pkg[]>;
|
|
74
|
+
info(name: string): Promise<PkgInfo>;
|
|
75
|
+
downloads?(name: string): Promise<DownloadObservations>;
|
|
76
|
+
/** Last npm publish date across any version, from the abbreviated
|
|
77
|
+
* multi-version doc -- the `/latest` endpoint `info()` uses carries no
|
|
78
|
+
* `time`/`modified` field at all. Optional, like `downloads`; undefined
|
|
79
|
+
* on any failure, never thrown. */
|
|
80
|
+
modifiedAt?(name: string): Promise<string | undefined>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `pi update --extension <source>` exits 0 and prints "Updated <source>"
|
|
85
|
+
* whether or not anything actually changed -- verified empirically against
|
|
86
|
+
* both a pinned, already-current source and an unpinned, already-latest
|
|
87
|
+
* one. reloadRequired/alreadyUpToDate are ground truth (on-disk resolved
|
|
88
|
+
* version, before vs. after), not that text. previousVersion/currentVersion
|
|
89
|
+
* are omitted when unknown (git:/https: sources, or no node_modules entry
|
|
90
|
+
* to read) -- reloadRequired then conservatively stays true rather than
|
|
91
|
+
* guessing.
|
|
92
|
+
*/
|
|
93
|
+
export interface UpdateOutcome {
|
|
94
|
+
output: string;
|
|
95
|
+
reloadRequired: boolean;
|
|
96
|
+
alreadyUpToDate: boolean;
|
|
97
|
+
pinned: boolean;
|
|
98
|
+
previousVersion?: string;
|
|
99
|
+
currentVersion?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Driven port: pi CLI mutations. */
|
|
103
|
+
export interface Installer {
|
|
104
|
+
install(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string>;
|
|
105
|
+
remove(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string>;
|
|
106
|
+
update(source: string, options?: { approved?: boolean; local?: boolean }): Promise<UpdateOutcome>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface InstalledPkg {
|
|
110
|
+
name: string;
|
|
111
|
+
pinned?: string;
|
|
112
|
+
installed?: string;
|
|
113
|
+
scope?: "global" | "project";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface UpdateEntry {
|
|
117
|
+
name: string;
|
|
118
|
+
installed: string;
|
|
119
|
+
latest: string;
|
|
120
|
+
detectedAt?: string;
|
|
121
|
+
scope?: "global" | "project";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface UpdatesSnapshot {
|
|
125
|
+
checkedAt: string;
|
|
126
|
+
updates: UpdateEntry[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Scope every query to pi packages unless the caller already qualified it. */
|
|
130
|
+
import { PI_PACKAGE_KEYWORD } from "../shared/constants.ts";
|
|
131
|
+
|
|
132
|
+
export function buildSearchQuery(q: string): string {
|
|
133
|
+
const t = q.trim();
|
|
134
|
+
if (t.includes("keywords:")) return t;
|
|
135
|
+
return t === "" ? PI_PACKAGE_KEYWORD : `${PI_PACKAGE_KEYWORD} ${t}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function clampLimit(v: number, def: number, max: number): number {
|
|
139
|
+
if (!Number.isFinite(v) || v <= 0) return def;
|
|
140
|
+
return Math.min(v, max);
|
|
141
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resources.ts — enable/disable individual extensions, skills, prompt
|
|
3
|
+
* templates, and themes declared by installed Pi packages, through the
|
|
4
|
+
* same documented settings.json packages[].{extensions,skills,prompts,
|
|
5
|
+
* themes} filter arrays pi config itself writes (docs/packages.md,
|
|
6
|
+
* "Enable and Disable Resources"). Packed owns this mutation the same way
|
|
7
|
+
* it owns every other settings.json change -- atomically, through the
|
|
8
|
+
* daemon -- without importing any of pi's own internal SettingsManager.
|
|
9
|
+
*
|
|
10
|
+
* Scope: npm: sources only for now. git:/local package resource discovery
|
|
11
|
+
* needs a different on-disk resolution strategy and is a documented
|
|
12
|
+
* follow-up; those entries are simply omitted from the list rather than
|
|
13
|
+
* reported inaccurately.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { discoverPackageResources, matchesPattern, type ResourceField, walk } from "../adoption/check.ts";
|
|
18
|
+
import { writeJsonAtomic } from "../shared/atomic-json.ts";
|
|
19
|
+
|
|
20
|
+
export type { ResourceField };
|
|
21
|
+
|
|
22
|
+
export const RESOURCE_FIELDS = ["extensions", "skills", "prompts", "themes"] as const satisfies readonly ResourceField[];
|
|
23
|
+
|
|
24
|
+
const MAX_PACKAGE_ENTRIES = 500;
|
|
25
|
+
const MAX_RESOURCE_FILES = 1_000;
|
|
26
|
+
|
|
27
|
+
export interface ResourceItem {
|
|
28
|
+
path: string;
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type PackageResources = { source: string; name: string; scope: "global" | "project" } & Record<ResourceField, ResourceItem[]>;
|
|
33
|
+
|
|
34
|
+
interface PackageEntry {
|
|
35
|
+
source: string;
|
|
36
|
+
extensions?: string[];
|
|
37
|
+
skills?: string[];
|
|
38
|
+
prompts?: string[];
|
|
39
|
+
themes?: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseEntry(raw: unknown): PackageEntry | undefined {
|
|
47
|
+
if (typeof raw === "string") return { source: raw };
|
|
48
|
+
if (!isRecord(raw) || typeof raw.source !== "string") return undefined;
|
|
49
|
+
const entry: PackageEntry = { source: raw.source };
|
|
50
|
+
for (const field of RESOURCE_FIELDS) {
|
|
51
|
+
const value = raw[field];
|
|
52
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) entry[field] = value as string[];
|
|
53
|
+
}
|
|
54
|
+
return entry;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readSettingsPackages(settingsPath: string): PackageEntry[] {
|
|
58
|
+
try {
|
|
59
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as { packages?: unknown[] };
|
|
60
|
+
return (settings.packages ?? [])
|
|
61
|
+
.map(parseEntry)
|
|
62
|
+
.filter((entry): entry is PackageEntry => entry !== undefined)
|
|
63
|
+
.slice(0, MAX_PACKAGE_ENTRIES);
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The bare npm package name for an npm: source, pinned or not. */
|
|
70
|
+
function npmName(source: string): string {
|
|
71
|
+
const spec = source.slice("npm:".length);
|
|
72
|
+
const at = spec.lastIndexOf("@");
|
|
73
|
+
return at > 0 ? spec.slice(0, at) : spec;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveInstalledDir(piHome: string, source: string): string | undefined {
|
|
77
|
+
if (!source.startsWith("npm:")) return undefined;
|
|
78
|
+
const dir = join(piHome, "npm", "node_modules", npmName(source));
|
|
79
|
+
return existsSync(dir) ? dir : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Layers a settings-level filter over a package's own discovered resource
|
|
83
|
+
* set, matching pi's own package-manager applyPatterns exactly (four
|
|
84
|
+
* ordered steps: bare patterns select from everything when none are given,
|
|
85
|
+
* then !excludes narrow, then +forceIncludes add back from the full base
|
|
86
|
+
* set overriding excludes, then -forceExcludes always win last). An
|
|
87
|
+
* omitted filter key loads everything; [] loads nothing. */
|
|
88
|
+
function applyFilter(baseFiles: string[], filter: string[] | undefined): Set<string> {
|
|
89
|
+
if (filter === undefined) return new Set(baseFiles);
|
|
90
|
+
if (filter.length === 0) return new Set(); // an explicit [] disables every resource of this type
|
|
91
|
+
const includes: string[] = [];
|
|
92
|
+
const excludes: string[] = [];
|
|
93
|
+
const forceIncludes: string[] = [];
|
|
94
|
+
const forceExcludes: string[] = [];
|
|
95
|
+
for (const raw of filter) {
|
|
96
|
+
if (raw.startsWith("+")) forceIncludes.push(raw.slice(1));
|
|
97
|
+
else if (raw.startsWith("-")) forceExcludes.push(raw.slice(1));
|
|
98
|
+
else if (raw.startsWith("!")) excludes.push(raw.slice(1));
|
|
99
|
+
else includes.push(raw);
|
|
100
|
+
}
|
|
101
|
+
let result =
|
|
102
|
+
includes.length === 0 ? [...baseFiles] : baseFiles.filter((file) => includes.some((pattern) => matchesPattern(file, pattern)));
|
|
103
|
+
if (excludes.length > 0) result = result.filter((file) => !excludes.some((pattern) => matchesPattern(file, pattern)));
|
|
104
|
+
if (forceIncludes.length > 0) {
|
|
105
|
+
for (const file of baseFiles) {
|
|
106
|
+
if (!result.includes(file) && forceIncludes.includes(file)) result.push(file);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (forceExcludes.length > 0) result = result.filter((file) => !forceExcludes.includes(file));
|
|
110
|
+
return new Set(result);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resourcesForEntry(entry: PackageEntry, piHome: string, scope: "global" | "project"): PackageResources | undefined {
|
|
114
|
+
const dir = resolveInstalledDir(piHome, entry.source);
|
|
115
|
+
if (!dir) return undefined;
|
|
116
|
+
let pkg: Record<string, unknown>;
|
|
117
|
+
try {
|
|
118
|
+
pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const { files } = walk(dir, MAX_RESOURCE_FILES, []);
|
|
123
|
+
const discovered = discoverPackageResources(dir, pkg, files);
|
|
124
|
+
const result = { source: entry.source, name: npmName(entry.source), scope } as PackageResources;
|
|
125
|
+
for (const field of RESOURCE_FIELDS) {
|
|
126
|
+
const enabled = applyFilter(discovered[field], entry[field]);
|
|
127
|
+
result[field] = discovered[field].map((path) => ({ path, enabled: enabled.has(path) })).sort((a, b) => a.path.localeCompare(b.path));
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** projectRoot, when given, resolves both .pi/settings.json (declarations)
|
|
133
|
+
* and .pi/ as the install home for project-local npm packages -- the same
|
|
134
|
+
* -l convention setup.ts already uses, so global and project packages of
|
|
135
|
+
* the same name never collide on one resolved directory. */
|
|
136
|
+
export function listPackageResources(piHome: string, projectRoot?: string): { global: PackageResources[]; project: PackageResources[] } {
|
|
137
|
+
const global = readSettingsPackages(join(piHome, "settings.json"))
|
|
138
|
+
.map((entry) => resourcesForEntry(entry, piHome, "global"))
|
|
139
|
+
.filter((item): item is PackageResources => item !== undefined);
|
|
140
|
+
if (!projectRoot) return { global, project: [] };
|
|
141
|
+
const projectHome = join(projectRoot, ".pi");
|
|
142
|
+
const project = readSettingsPackages(join(projectHome, "settings.json"))
|
|
143
|
+
.map((entry) => resourcesForEntry(entry, projectHome, "project"))
|
|
144
|
+
.filter((item): item is PackageResources => item !== undefined);
|
|
145
|
+
return { global, project };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The exact settings.json a toggle targets -- shared by the daemon's
|
|
149
|
+
* resources.toggle route and the standalone CLI fallback so they can never
|
|
150
|
+
* drift onto two different resolutions of the same package's settings. */
|
|
151
|
+
export function resolveToggleSettingsPath(piHome: string, projectRoot?: string): string {
|
|
152
|
+
return projectRoot ? join(projectRoot, ".pi", "settings.json") : join(piHome, "settings.json");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function atomicWriteJson(path: string, value: unknown): Promise<void> {
|
|
156
|
+
await writeJsonAtomic(path, value, { mode: 0o644, pretty: true });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface ToggleResourceInput {
|
|
160
|
+
settingsPath: string;
|
|
161
|
+
source: string;
|
|
162
|
+
field: ResourceField;
|
|
163
|
+
path: string;
|
|
164
|
+
enabled: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Toggles one resource's enabled state by rewriting its package entry's
|
|
168
|
+
* filter array with a minimal +path/-path override -- never touches the
|
|
169
|
+
* package's own glob-based defaults, and is idempotent for the same path.
|
|
170
|
+
* Matches pi's own pi config strip-and-append strategy exactly, including
|
|
171
|
+
* its one known edge case: re-enabling a single resource out of an
|
|
172
|
+
* explicit [] (disable-all-of-this-type) state re-enables the whole type,
|
|
173
|
+
* because the array stops being empty and the general apply-patterns
|
|
174
|
+
* algorithm treats "no bare include pattern" as "include everything" once
|
|
175
|
+
* it is no longer literally []. This is upstream's real behavior, not a
|
|
176
|
+
* Packed-specific bug -- parity with pi's own semantics matters more than
|
|
177
|
+
* a locally "nicer" model the actual loader wouldn't agree with. */
|
|
178
|
+
export async function toggleResource(input: ToggleResourceInput): Promise<{ ok: boolean; error?: string }> {
|
|
179
|
+
let settings: Record<string, unknown>;
|
|
180
|
+
try {
|
|
181
|
+
settings = JSON.parse(readFileSync(input.settingsPath, "utf8"));
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
184
|
+
}
|
|
185
|
+
const packages = Array.isArray(settings.packages) ? [...settings.packages] : [];
|
|
186
|
+
const index = packages.findIndex((raw) => parseEntry(raw)?.source === input.source);
|
|
187
|
+
if (index === -1) return { ok: false, error: `package not found in settings: ${input.source}` };
|
|
188
|
+
const existing = parseEntry(packages[index]);
|
|
189
|
+
if (!existing) return { ok: false, error: `invalid package entry: ${input.source}` };
|
|
190
|
+
const current = existing[input.field] ?? [];
|
|
191
|
+
const withoutOverride = current.filter((entry) => entry !== `+${input.path}` && entry !== `-${input.path}`);
|
|
192
|
+
const updated = [...withoutOverride, `${input.enabled ? "+" : "-"}${input.path}`];
|
|
193
|
+
const merged: Record<string, unknown> =
|
|
194
|
+
typeof packages[index] === "string" ? { source: packages[index] } : { ...(packages[index] as Record<string, unknown>) };
|
|
195
|
+
merged[input.field] = updated;
|
|
196
|
+
packages[index] = merged;
|
|
197
|
+
try {
|
|
198
|
+
await atomicWriteJson(input.settingsPath, { ...settings, packages });
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
201
|
+
}
|
|
202
|
+
return { ok: true };
|
|
203
|
+
}
|