@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.
Files changed (94) hide show
  1. package/dist/client.d.ts +109 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +1 -0
  4. package/dist/protocol.d.ts +221 -0
  5. package/dist/protocol.d.ts.map +1 -0
  6. package/dist/protocol.js +1 -0
  7. package/extension/src/{permission.ts → approval/permission.ts} +1 -1
  8. package/extension/src/index.ts +1 -1
  9. package/extension/src/packed.ts +2 -2
  10. package/extension/src/{discover.ts → tabs/discover.ts} +4 -4
  11. package/extension/src/{resource-config.ts → tabs/resource-config.ts} +4 -4
  12. package/extension/src/{security-tui.ts → tabs/security-tui.ts} +3 -3
  13. package/extension/src/tool-output.ts +1 -1
  14. package/extension/src/tools.ts +2 -2
  15. package/extension/src/tui.ts +4 -4
  16. package/package.json +31 -8
  17. package/service/schema/pi-setup-v1.schema.json +70 -0
  18. package/service/setup/danypops-ecosystem.pi-setup.json +15 -0
  19. package/service/src/adoption/advisories.ts +268 -0
  20. package/service/src/adoption/check.ts +872 -0
  21. package/service/src/adoption/commit-freshness.ts +167 -0
  22. package/service/src/adoption/doctor.ts +135 -0
  23. package/service/src/adoption/install-validation.ts +187 -0
  24. package/service/src/adoption/pack.ts +291 -0
  25. package/service/src/adoption/score.ts +466 -0
  26. package/service/src/adoption/smoke-child.ts +113 -0
  27. package/service/src/adoption/smoke.ts +282 -0
  28. package/service/src/cli/cli.ts +926 -0
  29. package/service/src/daemon/cleanup.ts +76 -0
  30. package/service/src/daemon/client.ts +412 -0
  31. package/service/src/daemon/daemon-service.ts +249 -0
  32. package/service/src/daemon/daemon.ts +110 -0
  33. package/service/src/daemon/service.ts +664 -0
  34. package/service/src/daemon/watcher.ts +92 -0
  35. package/service/src/index/build-index.ts +256 -0
  36. package/service/src/packages/catalog.ts +61 -0
  37. package/service/src/packages/db.ts +224 -0
  38. package/service/src/packages/install.ts +60 -0
  39. package/service/src/packages/installed.ts +123 -0
  40. package/service/src/packages/package.ts +141 -0
  41. package/service/src/packages/resources.ts +203 -0
  42. package/service/src/pi/pi-version.ts +171 -0
  43. package/service/src/public/atomic-json.ts +32 -0
  44. package/service/src/public/client.ts +277 -0
  45. package/service/src/public/protocol.ts +169 -0
  46. package/service/src/publish/publish.ts +855 -0
  47. package/service/src/registry/registry.ts +246 -0
  48. package/service/src/security/security.ts +128 -0
  49. package/service/src/self-update/self-update.ts +148 -0
  50. package/service/src/setup/setup.ts +761 -0
  51. package/service/src/shared/atomic-json.ts +33 -0
  52. package/service/src/shared/cache.ts +21 -0
  53. package/service/src/shared/constants.ts +73 -0
  54. package/service/src/shared/log.ts +21 -0
  55. package/service/src/shared/paths.ts +88 -0
  56. package/service/src/shared/state.ts +15 -0
  57. package/service/src/shared/version.ts +46 -0
  58. package/service/test/advisories.test.ts +287 -0
  59. package/service/test/check.test.ts +368 -0
  60. package/service/test/cleanup.test.ts +220 -0
  61. package/service/test/cli.test.ts +1303 -0
  62. package/service/test/core.test.ts +181 -0
  63. package/service/test/daemon-kit-migration.test.ts +181 -0
  64. package/service/test/daemon-service.test.ts +238 -0
  65. package/service/test/db.test.ts +178 -0
  66. package/service/test/doctor.test.ts +234 -0
  67. package/service/test/domain.test.ts +291 -0
  68. package/service/test/fixtures/install-validation/broken-package/extension/index.ts +3 -0
  69. package/service/test/fixtures/install-validation/broken-package/package.json +8 -0
  70. package/service/test/fixtures/install-validation/healthy-package/extension/index.ts +3 -0
  71. package/service/test/fixtures/install-validation/healthy-package/package.json +8 -0
  72. package/service/test/fixtures/install-validation/no-manifest-package/package.json +5 -0
  73. package/service/test/index.test.ts +353 -0
  74. package/service/test/install-validation.test.ts +114 -0
  75. package/service/test/install.test.ts +113 -0
  76. package/service/test/log.test.ts +42 -0
  77. package/service/test/pack-score.test.ts +513 -0
  78. package/service/test/pi-version.test.ts +318 -0
  79. package/service/test/public-boundary.test.ts +54 -0
  80. package/service/test/public-client.test.ts +127 -0
  81. package/service/test/public-consumer.ts +8 -0
  82. package/service/test/publish.test.ts +333 -0
  83. package/service/test/registry-contract.test.ts +148 -0
  84. package/service/test/resources.test.ts +255 -0
  85. package/service/test/security.test.ts +89 -0
  86. package/service/test/self-update.test.ts +257 -0
  87. package/service/test/service.test.ts +555 -0
  88. package/service/test/setup.test.ts +375 -0
  89. package/service/test/smoke.test.ts +118 -0
  90. package/service/test/version.test.ts +37 -0
  91. package/service/tsconfig.consumer.json +13 -0
  92. package/service/tsconfig.public.json +12 -0
  93. /package/extension/src/{reload.ts → approval/reload.ts} +0 -0
  94. /package/extension/src/{discover-model.ts → tabs/discover-model.ts} +0 -0
@@ -0,0 +1,855 @@
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import type { Diagnostic } from "../adoption/check.ts";
4
+ import type { Registry } from "../packages/package.ts";
5
+
6
+ export const TRUST_NPM_VERSION = "11.15.0";
7
+ const MAX_MANIFEST_BYTES = 1024 * 1024;
8
+ const MAX_WORKFLOW_BYTES = 64 * 1024;
9
+ const MAX_COMMAND_OUTPUT = 8 * 1024;
10
+ const VERSION_TIMEOUT_MS = 10_000;
11
+ const PACKAGE_NAME = /^(@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i;
12
+ const MAX_WORKSPACE_WALK = 8;
13
+ const MAX_WORKSPACE_PACKAGES = 50;
14
+
15
+ /** Derives the per-package staged-publish workflow filename from a package
16
+ * name, so sibling packages in one workspace never collide on one file. */
17
+ export function stageWorkflowSlug(packageName: string): string {
18
+ const bare = packageName.includes("/") ? packageName.slice(packageName.lastIndexOf("/") + 1) : packageName;
19
+ const slug = bare
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9-]+/g, "-")
22
+ .replace(/^-+|-+$/g, "");
23
+ return slug || "package";
24
+ }
25
+
26
+ export function stageWorkflowFile(packageName: string): string {
27
+ return `${stageWorkflowSlug(packageName)}-stage-publish.yml`;
28
+ }
29
+
30
+ export interface PackageManagerSelection {
31
+ name: "bun" | "npm" | "pnpm" | "yarn";
32
+ version?: string;
33
+ }
34
+
35
+ export interface WorkflowInput {
36
+ packageManager: PackageManagerSelection;
37
+ scripts: string[];
38
+ /** Relative package directory to build/test from, when the workflow lives
39
+ * at a monorepo root rather than the package root itself. */
40
+ packageDir?: string;
41
+ /** Sibling workspace packages (name -> declared range) that must already
42
+ * be published on npm before this package's own stage can succeed. */
43
+ coreFirst?: Record<string, string>;
44
+ /** Package name slug used to scope this package's own release tag
45
+ * ($tagPrefix-v*), so two workspace siblings never share one trigger. */
46
+ tagPrefix?: string;
47
+ }
48
+
49
+ export interface VersionCommandResult {
50
+ code: number;
51
+ stdout: string;
52
+ stderr: string;
53
+ }
54
+ export type VersionCommand = () => Promise<VersionCommandResult>;
55
+ export type TrustStatusCommand = (packageName: string) => Promise<VersionCommandResult>;
56
+
57
+ export interface PublishSetupReport {
58
+ root: string;
59
+ ok: boolean;
60
+ wrote: boolean;
61
+ workflowPath: string;
62
+ packageName?: string;
63
+ repository?: string;
64
+ trustCommand?: string;
65
+ statusCommand?: string;
66
+ webUrl?: string;
67
+ diagnostics: Diagnostic[];
68
+ }
69
+
70
+ export interface PublishStatusReport {
71
+ root: string;
72
+ ready: boolean;
73
+ packageName?: string;
74
+ repository?: string;
75
+ workflowPath: string;
76
+ checks: {
77
+ packageExists: boolean;
78
+ repository: boolean;
79
+ workflow: boolean;
80
+ lockfile: boolean;
81
+ node: boolean;
82
+ npm: boolean;
83
+ trustedPublisher: "verified" | "not-verified" | "unknown";
84
+ /** true when every internal (workspace-sibling) dependency this package
85
+ * declares is already published on npm at a satisfying version. */
86
+ coreFirst: boolean;
87
+ /** Local machine login state (npm whoami) -- informative only, never
88
+ * blocks ready: CI publishes over OIDC trusted-publisher config, not
89
+ * local login. Surfaced so an interactive caller knows what to offer. */
90
+ loggedIn: boolean;
91
+ };
92
+ diagnostics: Diagnostic[];
93
+ nextSteps: string[];
94
+ }
95
+
96
+ interface PackageManifest {
97
+ name?: unknown;
98
+ version?: unknown;
99
+ repository?: unknown;
100
+ packageManager?: unknown;
101
+ scripts?: unknown;
102
+ publishConfig?: unknown;
103
+ private?: unknown;
104
+ workspaces?: unknown;
105
+ dependencies?: unknown;
106
+ }
107
+
108
+ interface WorkspaceContext {
109
+ workspaceRoot: string;
110
+ packageRelative: string;
111
+ isMonorepo: boolean;
112
+ rootManifest?: PackageManifest;
113
+ }
114
+
115
+ /** Sibling package name -> its directory relative to the workspace root. */
116
+ type WorkspaceSiblings = Map<string, string>;
117
+
118
+ const CHECKOUT_SHA = "d23441a48e516b6c34aea4fa41551a30e30af803";
119
+ const SETUP_NODE_SHA = "249970729cb0ef3589644e2896645e5dc5ba9c38";
120
+ const SETUP_BUN_SHA = "0c5077e51419868618aeaa5fe8019c62421857d6";
121
+
122
+ function installSteps(manager: PackageManagerSelection): string[] {
123
+ if (manager.name === "bun")
124
+ return [
125
+ ` - uses: oven-sh/setup-bun@${SETUP_BUN_SHA} # v2`,
126
+ " with:",
127
+ ` bun-version: "${manager.version ?? "1.3.14"}"`,
128
+ " - run: bun install --frozen-lockfile --ignore-scripts",
129
+ ];
130
+ if (manager.name === "npm") return [" - run: npm ci --ignore-scripts"];
131
+ const version = manager.version ?? (manager.name === "pnpm" ? "10" : "1.22.22");
132
+ return [
133
+ ` - run: npm install --global ${manager.name}@${version}`,
134
+ ` - run: ${manager.name} install --frozen-lockfile --ignore-scripts`,
135
+ ];
136
+ }
137
+
138
+ function scriptStep(manager: PackageManagerSelection, script: string, packageDir?: string): string {
139
+ const cwd = packageDir && packageDir !== "." ? ` --cwd ${packageDir}` : "";
140
+ if (manager.name === "npm") return ` - run: npm run ${script}${packageDir && packageDir !== "." ? ` --prefix ${packageDir}` : ""}`;
141
+ if (manager.name === "bun") return ` - run: bun run${cwd} ${script}`;
142
+ if (manager.name === "pnpm") return ` - run: pnpm --dir ${packageDir ?? "."} run ${script}`;
143
+ return ` - run: yarn --cwd ${packageDir ?? "."} run ${script}`;
144
+ }
145
+
146
+ /** One preflight step per internal dependency: reads the resolved version
147
+ * Just installed and fails the job outright if npm doesn't already carry a
148
+ * compatible published version -- mechanical core-first ordering, not a
149
+ * convention that can be skipped. */
150
+ function coreFirstSteps(coreFirst: Record<string, string>): string[] {
151
+ return Object.entries(coreFirst).flatMap(([name, range]) => [
152
+ ` - name: Verify ${name}@${range} is already published (core-first ordering)`,
153
+ " run: |",
154
+ ` version=$(node -p "require('./node_modules/${name}/package.json').version")`,
155
+ ` npm view "${name}@$version" version`,
156
+ ]);
157
+ }
158
+
159
+ export function renderStageWorkflow(input: WorkflowInput): string {
160
+ const coreFirst = input.coreFirst && Object.keys(input.coreFirst).length > 0 ? coreFirstSteps(input.coreFirst) : [];
161
+ const steps = input.scripts.map((script) => scriptStep(input.packageManager, script, input.packageDir));
162
+ // A monorepo package gets a slug-scoped tag ($slug-v*) so one tag push can
163
+ // never ambiguously trigger a sibling package's stage; a single-repo
164
+ // package keeps the original plain "v*" it always used.
165
+ const tagPattern = input.packageDir && input.packageDir !== "." && input.tagPrefix ? `${input.tagPrefix}-v*` : "v*";
166
+ const publishDir = input.packageDir && input.packageDir !== "." ? `\n working-directory: ${input.packageDir}` : "";
167
+ return [
168
+ "name: Stage npm package",
169
+ "",
170
+ "on:",
171
+ " workflow_dispatch:",
172
+ " push:",
173
+ " tags:",
174
+ ` - "${tagPattern}"`,
175
+ "",
176
+ "permissions:",
177
+ " contents: read",
178
+ " id-token: write",
179
+ "",
180
+ "concurrency:",
181
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: literal GitHub Actions expression syntax in generated YAML, not JS interpolation
182
+ " group: stage-npm-${{ github.ref }}",
183
+ " cancel-in-progress: false",
184
+ "",
185
+ "jobs:",
186
+ " stage:",
187
+ " runs-on: ubuntu-latest",
188
+ " timeout-minutes: 20",
189
+ " steps:",
190
+ ` - uses: actions/checkout@${CHECKOUT_SHA} # v6`,
191
+ ` - uses: actions/setup-node@${SETUP_NODE_SHA} # v6`,
192
+ " with:",
193
+ ' node-version: "24"',
194
+ ' registry-url: "https://registry.npmjs.org"',
195
+ " package-manager-cache: false",
196
+ ...installSteps(input.packageManager),
197
+ ...coreFirst,
198
+ ...steps,
199
+ ...(input.packageManager.name === "bun"
200
+ ? []
201
+ : [` - uses: oven-sh/setup-bun@${SETUP_BUN_SHA} # v2`, " with:", ' bun-version: "1.3.14"']),
202
+ // "latest", not VERSION: the version currently being staged cannot exist
203
+ // on npm yet, and one already-published packed release exists by the
204
+ // time this workflow can be generated at all (setup requires it).
205
+ ` - run: bunx --bun @danypops/pi-packed@latest check ${input.packageDir ?? "."}`,
206
+ ` - run: npm install --global npm@${TRUST_NPM_VERSION}`,
207
+ " - run: npm --version",
208
+ ` - run: npm stage publish --access public --provenance --ignore-scripts${publishDir}`,
209
+ "",
210
+ ].join("\n");
211
+ }
212
+
213
+ function parseVersion(value: string): [number, number, number] | undefined {
214
+ const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
215
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
216
+ }
217
+
218
+ export function versionAtLeast(actual: string, minimum: string): boolean {
219
+ const left = parseVersion(actual);
220
+ const right = parseVersion(minimum);
221
+ if (!left || !right) return false;
222
+ for (let index = 0; index < 3; index++) {
223
+ if (left[index]! > right[index]!) return true;
224
+ if (left[index]! < right[index]!) return false;
225
+ }
226
+ return true;
227
+ }
228
+
229
+ /** Supports the two range shapes this workspace actually uses (exact, ^, ~);
230
+ * returns undefined rather than guessing for anything broader. */
231
+ export function satisfiesRange(version: string, range: string): boolean | undefined {
232
+ const actual = parseVersion(version);
233
+ if (!actual) return undefined;
234
+ const trimmed = range.trim();
235
+ const exact = parseVersion(trimmed);
236
+ if (exact) return actual[0] === exact[0] && actual[1] === exact[1] && actual[2] === exact[2];
237
+ const caret = trimmed.match(/^\^(\d+)\.(\d+)\.(\d+)/);
238
+ if (caret) {
239
+ const min: [number, number, number] = [Number(caret[1]), Number(caret[2]), Number(caret[3])];
240
+ if (actual[0] !== min[0]) return false;
241
+ if (min[0] === 0) return actual[1] === min[1] && actual[2] >= min[2];
242
+ return actual[1] > min[1] || (actual[1] === min[1] && actual[2] >= min[2]);
243
+ }
244
+ const tilde = trimmed.match(/^~(\d+)\.(\d+)\.(\d+)/);
245
+ if (tilde) {
246
+ const min: [number, number, number] = [Number(tilde[1]), Number(tilde[2]), Number(tilde[3])];
247
+ return actual[0] === min[0] && actual[1] === min[1] && actual[2] >= min[2];
248
+ }
249
+ return undefined;
250
+ }
251
+
252
+ export async function runBounded(command: string[]): Promise<VersionCommandResult> {
253
+ const proc = Bun.spawn(command, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
254
+ const timer = setTimeout(() => proc.kill(), VERSION_TIMEOUT_MS);
255
+ const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
256
+ clearTimeout(timer);
257
+ return { code, stdout: stdout.slice(0, MAX_COMMAND_OUTPUT), stderr: stderr.slice(0, MAX_COMMAND_OUTPUT) };
258
+ }
259
+
260
+ export const readNpmVersion: VersionCommand = () => runBounded(["npm", "--version"]);
261
+ export const readTrustStatus: TrustStatusCommand = (packageName) => runBounded(["npm", "trust", "list", packageName, "--json"]);
262
+ export const readNpmWhoami: VersionCommand = () => runBounded(["npm", "whoami"]);
263
+
264
+ export interface InteractiveRunResult {
265
+ ok: boolean;
266
+ code: number;
267
+ }
268
+
269
+ export async function runInherited(command: string[]): Promise<InteractiveRunResult> {
270
+ try {
271
+ const proc = Bun.spawn(command, { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
272
+ const code = await proc.exited;
273
+ return { ok: code === 0, code };
274
+ } catch {
275
+ // no such binary, or the platform opener isn't installed -- the caller
276
+ // always has the raw URL/command to fall back to, never blocks on this.
277
+ return { ok: false, code: -1 };
278
+ }
279
+ }
280
+
281
+ /** Runs the real, interactive `npm login --auth-type=web`. npm's own
282
+ * process polls for completion and writes ~/.npmrc itself -- no token ever
283
+ * passes through Packed. Headless by default (`--no-browser`, npm's own
284
+ * documented config: print the URL, never auto-launch anything); pass
285
+ * `openBrowserAuto: true` only when a human on their own desktop terminal
286
+ * explicitly opted in. Only ever invoked after the caller's own explicit
287
+ * confirmation; never from a non-TTY or scripted context. */
288
+ export function runNpmLoginWeb(openBrowserAuto = false): Promise<InteractiveRunResult> {
289
+ const args = ["npm", "login", "--auth-type=web"];
290
+ if (!openBrowserAuto) args.push("--no-browser");
291
+ return runInherited(args);
292
+ }
293
+
294
+ /** Pure command construction, kept separate from the actual spawn so tests
295
+ * never risk invoking a real browser. */
296
+ export function browserOpenCommand(url: string): string[] {
297
+ return process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
298
+ }
299
+
300
+ /** Best-effort browser open for a human-driven web handoff (npm's own
301
+ * Trusted Publisher configuration UI, not a CLI command Packed constructs
302
+ * itself). Only ever called when a human explicitly opted in -- the default
303
+ * interactive path never spawns this, it only prints the URL. Failure is
304
+ * silent and non-fatal -- the caller always prints the URL too, so a
305
+ * missing/unknown opener never blocks the human. */
306
+ export function openBrowser(url: string): Promise<InteractiveRunResult> {
307
+ return runInherited(browserOpenCommand(url));
308
+ }
309
+
310
+ function readManifest(root: string): PackageManifest {
311
+ const path = join(root, "package.json");
312
+ const bytes = readFileSync(path);
313
+ if (bytes.byteLength > MAX_MANIFEST_BYTES) throw new Error("package.json exceeds 1 MiB");
314
+ const value = JSON.parse(bytes.toString()) as unknown;
315
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("package.json must contain an object");
316
+ return value as PackageManifest;
317
+ }
318
+
319
+ function isWorkspaceManifest(manifest: PackageManifest): boolean {
320
+ return Array.isArray(manifest.workspaces) || (Boolean(manifest.workspaces) && typeof manifest.workspaces === "object");
321
+ }
322
+
323
+ /** Walks up from a package directory, bounded, looking for the nearest
324
+ * ancestor package.json declaring `workspaces`. Falls back to treating the
325
+ * package itself as the root -- the original, still-supported single-repo
326
+ * shape. */
327
+ function resolveWorkspace(packageDir: string): WorkspaceContext {
328
+ let current = packageDir;
329
+ for (let step = 0; step < MAX_WORKSPACE_WALK; step++) {
330
+ const parent = dirname(current);
331
+ if (parent === current) break;
332
+ current = parent;
333
+ try {
334
+ const manifest = readManifest(current);
335
+ if (isWorkspaceManifest(manifest)) {
336
+ return { workspaceRoot: current, packageRelative: relative(current, packageDir) || ".", isMonorepo: true, rootManifest: manifest };
337
+ }
338
+ } catch {
339
+ /* not a manifest here; keep walking */
340
+ }
341
+ }
342
+ return { workspaceRoot: packageDir, packageRelative: ".", isMonorepo: false };
343
+ }
344
+
345
+ /** Bounded scan of workspaceRoot/packages/* for sibling package names --
346
+ * used only to detect internal (workspace-to-workspace) dependencies that
347
+ * need core-first ordering, never to walk arbitrary depth. */
348
+ function workspaceSiblings(workspaceRoot: string): WorkspaceSiblings {
349
+ const siblings: WorkspaceSiblings = new Map();
350
+ let entries: string[];
351
+ try {
352
+ entries = readdirSync(join(workspaceRoot, "packages"));
353
+ } catch {
354
+ return siblings;
355
+ }
356
+ for (const entry of entries.slice(0, MAX_WORKSPACE_PACKAGES)) {
357
+ try {
358
+ const manifest = readManifest(join(workspaceRoot, "packages", entry));
359
+ if (typeof manifest.name === "string") siblings.set(manifest.name, join("packages", entry));
360
+ } catch {}
361
+ }
362
+ return siblings;
363
+ }
364
+
365
+ function dependencyRanges(manifest: PackageManifest): Record<string, string> {
366
+ if (!manifest.dependencies || typeof manifest.dependencies !== "object" || Array.isArray(manifest.dependencies)) return {};
367
+ const ranges: Record<string, string> = {};
368
+ for (const [name, value] of Object.entries(manifest.dependencies as Record<string, unknown>))
369
+ if (typeof value === "string") ranges[name] = value;
370
+ return ranges;
371
+ }
372
+
373
+ /** Internal (workspace-sibling) dependency ranges only -- the subset that
374
+ * needs core-first publish ordering, as opposed to ordinary external deps. */
375
+ function internalDependencyRanges(manifest: PackageManifest, siblings: WorkspaceSiblings): Record<string, string> {
376
+ const ranges = dependencyRanges(manifest);
377
+ return Object.fromEntries(Object.entries(ranges).filter(([name]) => siblings.has(name)));
378
+ }
379
+ function repositoryString(value: unknown): string | undefined {
380
+ if (typeof value === "string") return value;
381
+ if (value && typeof value === "object" && typeof (value as Record<string, unknown>).url === "string")
382
+ return (value as Record<string, unknown>).url as string;
383
+ return undefined;
384
+ }
385
+
386
+ export function githubRepository(value: unknown): string | undefined {
387
+ const raw = repositoryString(value)?.trim();
388
+ if (!raw || /[?#]/.test(raw)) return undefined;
389
+ const normalized = raw
390
+ .replace(/^git\+/, "")
391
+ .replace(/^git@github\.com:/, "https://github.com/")
392
+ .replace(/^ssh:\/\/git@github\.com\//, "https://github.com/")
393
+ .replace(/\.git$/, "");
394
+ let url: URL;
395
+ try {
396
+ url = new URL(normalized);
397
+ } catch {
398
+ return undefined;
399
+ }
400
+ if (url.hostname.toLowerCase() !== "github.com" || url.username || url.password) return undefined;
401
+ const parts = url.pathname.split("/").filter(Boolean);
402
+ if (parts.length !== 2 || !parts.every((part) => /^[A-Za-z0-9_.-]+$/.test(part))) return undefined;
403
+ return `${parts[0]}/${parts[1]}`;
404
+ }
405
+
406
+ function selectPackageManager(root: string, value: unknown): PackageManagerSelection {
407
+ if (typeof value === "string") {
408
+ const match = value.match(/^(bun|npm|pnpm|yarn)@(.+)$/);
409
+ if (match) return { name: match[1] as PackageManagerSelection["name"], version: match[2]!.slice(0, 32) };
410
+ }
411
+ if (existsSync(join(root, "bun.lock")) || existsSync(join(root, "bun.lockb"))) return { name: "bun", version: "1.3.14" };
412
+ if (existsSync(join(root, "pnpm-lock.yaml"))) return { name: "pnpm" };
413
+ if (existsSync(join(root, "yarn.lock"))) return { name: "yarn" };
414
+ return { name: "npm" };
415
+ }
416
+
417
+ function hasRestrictedAccess(value: unknown): boolean {
418
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
419
+ const access = (value as Record<string, unknown>).access;
420
+ return access === "restricted" || access === "private";
421
+ }
422
+
423
+ function hasLockfile(root: string, manager: PackageManagerSelection): boolean {
424
+ if (manager.name === "bun") return existsSync(join(root, "bun.lock")) || existsSync(join(root, "bun.lockb"));
425
+ if (manager.name === "pnpm") return existsSync(join(root, "pnpm-lock.yaml"));
426
+ if (manager.name === "yarn") return existsSync(join(root, "yarn.lock"));
427
+ return existsSync(join(root, "package-lock.json")) || existsSync(join(root, "npm-shrinkwrap.json"));
428
+ }
429
+
430
+ function selectedScripts(value: unknown): string[] {
431
+ if (!value || typeof value !== "object" || Array.isArray(value)) return [];
432
+ const scripts = value as Record<string, unknown>;
433
+ return ["build", "check", "typecheck", "test"].filter((name) => typeof scripts[name] === "string");
434
+ }
435
+
436
+ function trustCommand(packageName: string, workflowFile: string, repository: string): string {
437
+ return `npm trust github ${packageName} --repo ${repository} --file ${workflowFile} --allow-stage-publish`;
438
+ }
439
+
440
+ export function npmWebUrl(packageName: string): string {
441
+ return `https://www.npmjs.com/package/${packageName}/access`;
442
+ }
443
+
444
+ function diagnostic(code: string, severity: Diagnostic["severity"], path: string, message: string, fix?: string): Diagnostic {
445
+ return { code, severity, path, message: message.slice(0, 2_000), ...(fix ? { fix: fix.slice(0, 2_000) } : {}) };
446
+ }
447
+
448
+ async function packageExists(registry: Registry, name: string): Promise<boolean> {
449
+ try {
450
+ return (await registry.info(name)).name === name;
451
+ } catch {
452
+ return false;
453
+ }
454
+ }
455
+
456
+ function workflowInput(ws: WorkspaceContext, manifest: PackageManifest, siblings: WorkspaceSiblings): WorkflowInput {
457
+ const manager = selectPackageManager(ws.workspaceRoot, manifest.packageManager ?? ws.rootManifest?.packageManager);
458
+ const packageName = typeof manifest.name === "string" ? manifest.name : undefined;
459
+ return {
460
+ packageManager: manager,
461
+ scripts: selectedScripts(manifest.scripts),
462
+ packageDir: ws.isMonorepo ? ws.packageRelative : undefined,
463
+ coreFirst: ws.isMonorepo ? internalDependencyRanges(manifest, siblings) : undefined,
464
+ tagPrefix: ws.isMonorepo && packageName ? stageWorkflowSlug(packageName) : undefined,
465
+ };
466
+ }
467
+
468
+ function workflowIsExpected(path: string, expected: string): boolean {
469
+ try {
470
+ const stat = lstatSync(path);
471
+ if (!stat.isFile() || stat.size > MAX_WORKFLOW_BYTES) return false;
472
+ return readFileSync(path, "utf8") === expected;
473
+ } catch {
474
+ return false;
475
+ }
476
+ }
477
+
478
+ export class PublishManager {
479
+ constructor(
480
+ private readonly registry: Registry,
481
+ private readonly versionCommand: VersionCommand = readNpmVersion,
482
+ private readonly trustStatusCommand: TrustStatusCommand = readTrustStatus,
483
+ private readonly whoamiCommand: VersionCommand = readNpmWhoami,
484
+ ) {}
485
+
486
+ async setup(projectPath: string, options: { force?: boolean } = {}): Promise<PublishSetupReport> {
487
+ const root = resolve(projectPath);
488
+ const ws = resolveWorkspace(root);
489
+ const diagnostics: Diagnostic[] = [];
490
+ let manifest: PackageManifest;
491
+ try {
492
+ manifest = readManifest(root);
493
+ } catch (error) {
494
+ return {
495
+ root,
496
+ ok: false,
497
+ wrote: false,
498
+ workflowPath: join(ws.workspaceRoot, ".github/workflows/stage-publish.yml"),
499
+ diagnostics: [
500
+ diagnostic("PUBLISH_MANIFEST_INVALID", "error", "package.json", error instanceof Error ? error.message : String(error)),
501
+ ],
502
+ };
503
+ }
504
+ const packageName = typeof manifest.name === "string" && PACKAGE_NAME.test(manifest.name) ? manifest.name : undefined;
505
+ const workflowFile = stageWorkflowFile(packageName ?? basename(root));
506
+ const workflowRelativePath = `.github/workflows/${workflowFile}`;
507
+ const workflowPath = join(ws.workspaceRoot, workflowRelativePath);
508
+ if (!packageName)
509
+ diagnostics.push(diagnostic("PUBLISH_PACKAGE_NAME_INVALID", "error", "package.json", "package name is missing or invalid"));
510
+ if (manifest.private === true || hasRestrictedAccess(manifest.publishConfig))
511
+ diagnostics.push(
512
+ diagnostic(
513
+ "PUBLISH_PRIVATE_PACKAGE",
514
+ "error",
515
+ "package.json",
516
+ "the generated public staged-publish workflow does not support private or restricted packages",
517
+ ),
518
+ );
519
+ const repository = githubRepository(manifest.repository);
520
+ if (!repository)
521
+ diagnostics.push(
522
+ diagnostic(
523
+ "PUBLISH_GITHUB_REPOSITORY_REQUIRED",
524
+ "error",
525
+ "package.json",
526
+ "repository must identify one credential-free GitHub owner/repository",
527
+ ),
528
+ );
529
+ const exists = packageName ? await packageExists(this.registry, packageName) : false;
530
+ if (packageName && !exists)
531
+ diagnostics.push(
532
+ diagnostic(
533
+ "PUBLISH_PACKAGE_NOT_FOUND",
534
+ "error",
535
+ "package.json",
536
+ "trusted publishing can only be configured after the package exists on npm",
537
+ `Complete the first authenticated publish in npm, then rerun packed publish setup. Open ${npmWebUrl(packageName)}`,
538
+ ),
539
+ );
540
+ const manager = selectPackageManager(ws.workspaceRoot, manifest.packageManager ?? ws.rootManifest?.packageManager);
541
+ if (!hasLockfile(ws.workspaceRoot, manager))
542
+ diagnostics.push(
543
+ diagnostic(
544
+ "PUBLISH_LOCKFILE_REQUIRED",
545
+ "error",
546
+ "package.json",
547
+ `a committed ${manager.name} lockfile is required for deterministic CI installation`,
548
+ ),
549
+ );
550
+ const npm = await this.versionCommand().catch((error) => ({
551
+ code: 1,
552
+ stdout: "",
553
+ stderr: error instanceof Error ? error.message : String(error),
554
+ }));
555
+ if (npm.code !== 0 || !versionAtLeast(npm.stdout, TRUST_NPM_VERSION))
556
+ diagnostics.push(
557
+ diagnostic(
558
+ "PUBLISH_NPM_VERSION_LOW",
559
+ "warning",
560
+ "npm",
561
+ `npm ${TRUST_NPM_VERSION} or newer is required for trust management`,
562
+ `npm install --global npm@^${TRUST_NPM_VERSION}`,
563
+ ),
564
+ );
565
+ if (existsSync(workflowPath) && !options.force)
566
+ diagnostics.push(
567
+ diagnostic(
568
+ "PUBLISH_WORKFLOW_EXISTS",
569
+ "error",
570
+ workflowRelativePath,
571
+ "workflow already exists; Packed will not overwrite it without --force",
572
+ ),
573
+ );
574
+ if (existsSync(workflowPath) && lstatSync(workflowPath).isSymbolicLink())
575
+ diagnostics.push(diagnostic("PUBLISH_WORKFLOW_SYMLINK", "error", workflowRelativePath, "refusing to overwrite a workflow symlink"));
576
+ if (diagnostics.some((item) => item.severity === "error")) {
577
+ return {
578
+ root,
579
+ ok: false,
580
+ wrote: false,
581
+ workflowPath,
582
+ packageName,
583
+ repository,
584
+ trustCommand: packageName && repository ? trustCommand(packageName, workflowFile, repository) : undefined,
585
+ webUrl: packageName ? npmWebUrl(packageName) : undefined,
586
+ diagnostics,
587
+ };
588
+ }
589
+ const siblings = ws.isMonorepo ? workspaceSiblings(ws.workspaceRoot) : new Map<string, string>();
590
+ const workflow = renderStageWorkflow(workflowInput(ws, manifest, siblings));
591
+ mkdirSync(dirname(workflowPath), { recursive: true });
592
+ if (options.force && existsSync(workflowPath)) {
593
+ const temporary = join(dirname(workflowPath), `.${basename(workflowPath)}.${process.pid}.tmp`);
594
+ try {
595
+ writeFileSync(temporary, workflow, { flag: "wx", mode: 0o644 });
596
+ renameSync(temporary, workflowPath);
597
+ } finally {
598
+ rmSync(temporary, { force: true });
599
+ }
600
+ } else writeFileSync(workflowPath, workflow, { flag: "wx", mode: 0o644 });
601
+ return {
602
+ root,
603
+ ok: true,
604
+ wrote: true,
605
+ workflowPath,
606
+ packageName,
607
+ repository,
608
+ trustCommand: trustCommand(packageName!, workflowFile, repository!),
609
+ statusCommand: `packed publish status ${root}`,
610
+ webUrl: npmWebUrl(packageName!),
611
+ diagnostics,
612
+ };
613
+ }
614
+
615
+ async status(projectPath: string): Promise<PublishStatusReport> {
616
+ const root = resolve(projectPath);
617
+ const ws = resolveWorkspace(root);
618
+ const diagnostics: Diagnostic[] = [];
619
+ let manifest: PackageManifest;
620
+ try {
621
+ manifest = readManifest(root);
622
+ } catch (error) {
623
+ return {
624
+ root,
625
+ ready: false,
626
+ workflowPath: join(ws.workspaceRoot, ".github/workflows/stage-publish.yml"),
627
+ checks: {
628
+ packageExists: false,
629
+ repository: false,
630
+ workflow: false,
631
+ lockfile: false,
632
+ node: false,
633
+ npm: false,
634
+ trustedPublisher: "unknown",
635
+ coreFirst: false,
636
+ loggedIn: false,
637
+ },
638
+ diagnostics: [
639
+ diagnostic("PUBLISH_MANIFEST_INVALID", "error", "package.json", error instanceof Error ? error.message : String(error)),
640
+ ],
641
+ nextSteps: [],
642
+ };
643
+ }
644
+ const packageName = typeof manifest.name === "string" && PACKAGE_NAME.test(manifest.name) ? manifest.name : undefined;
645
+ const workflowFile = stageWorkflowFile(packageName ?? basename(root));
646
+ const workflowRelativePath = `.github/workflows/${workflowFile}`;
647
+ const workflowPath = join(ws.workspaceRoot, workflowRelativePath);
648
+ const repository = githubRepository(manifest.repository);
649
+ const exists = packageName ? await packageExists(this.registry, packageName) : false;
650
+ const manager = selectPackageManager(ws.workspaceRoot, manifest.packageManager ?? ws.rootManifest?.packageManager);
651
+ const lockfile = hasLockfile(ws.workspaceRoot, manager);
652
+ const siblings = ws.isMonorepo ? workspaceSiblings(ws.workspaceRoot) : new Map<string, string>();
653
+ const internal = ws.isMonorepo ? internalDependencyRanges(manifest, siblings) : {};
654
+ const expected = renderStageWorkflow(workflowInput(ws, manifest, siblings));
655
+ const workflow = workflowIsExpected(workflowPath, expected);
656
+ const npmResult = await this.versionCommand().catch(() => ({ code: 1, stdout: "", stderr: "" }));
657
+ const npm = npmResult.code === 0 && versionAtLeast(npmResult.stdout, TRUST_NPM_VERSION);
658
+ const node = workflow && expected.includes('node-version: "24"');
659
+ const trustedPublisher =
660
+ packageName && repository && npm ? await this.trustedPublisherStatus(packageName, workflowFile, repository) : "unknown";
661
+ const coreFirst = await this.coreFirstStatus(internal, diagnostics);
662
+ const loggedIn = (await this.whoamiCommand().catch(() => ({ code: 1, stdout: "", stderr: "" }))).code === 0;
663
+ if (!loggedIn)
664
+ diagnostics.push(
665
+ diagnostic(
666
+ "PUBLISH_NPM_NOT_LOGGED_IN",
667
+ "warning",
668
+ "npm",
669
+ "not logged in to npm on this machine; trust configuration requires an authenticated npm session",
670
+ "npm login --auth-type=web",
671
+ ),
672
+ );
673
+ if (!packageName)
674
+ diagnostics.push(diagnostic("PUBLISH_PACKAGE_NAME_INVALID", "error", "package.json", "package name is missing or invalid"));
675
+ if (!exists) diagnostics.push(diagnostic("PUBLISH_PACKAGE_NOT_FOUND", "error", "package.json", "package does not exist on npm"));
676
+ if (!repository)
677
+ diagnostics.push(
678
+ diagnostic("PUBLISH_GITHUB_REPOSITORY_REQUIRED", "error", "package.json", "valid GitHub repository metadata is required"),
679
+ );
680
+ if (!lockfile)
681
+ diagnostics.push(
682
+ diagnostic(
683
+ "PUBLISH_LOCKFILE_REQUIRED",
684
+ "error",
685
+ "package.json",
686
+ `a committed ${manager.name} lockfile is required for deterministic CI installation`,
687
+ ),
688
+ );
689
+ if (!workflow)
690
+ diagnostics.push(
691
+ diagnostic(
692
+ "PUBLISH_WORKFLOW_MISSING_OR_STALE",
693
+ "error",
694
+ workflowRelativePath,
695
+ "generated staged-publish workflow is missing or differs from current policy",
696
+ "rerun packed publish setup --force after reviewing the diff",
697
+ ),
698
+ );
699
+ if (!npm)
700
+ diagnostics.push(
701
+ diagnostic(
702
+ "PUBLISH_NPM_VERSION_LOW",
703
+ "warning",
704
+ "npm",
705
+ `local npm ${TRUST_NPM_VERSION} or newer is required to configure trust`,
706
+ `npm install --global npm@^${TRUST_NPM_VERSION}`,
707
+ ),
708
+ );
709
+ if (trustedPublisher === "not-verified")
710
+ diagnostics.push(
711
+ diagnostic(
712
+ "PUBLISH_TRUST_MISMATCH",
713
+ "error",
714
+ "npm",
715
+ "npm trusted publisher does not match the GitHub repository, workflow file, and stage-only permission",
716
+ ),
717
+ );
718
+ if (trustedPublisher === "unknown")
719
+ diagnostics.push(
720
+ diagnostic(
721
+ "PUBLISH_TRUST_UNKNOWN",
722
+ "warning",
723
+ "npm",
724
+ "trusted publisher status could not be read; authenticate npm or use the web handoff",
725
+ ),
726
+ );
727
+ const command = packageName && repository ? trustCommand(packageName, workflowFile, repository) : undefined;
728
+ const nextSteps = [
729
+ ...(command ? [`Run: ${command}`] : []),
730
+ ...(packageName ? [`Or configure Trusted Publisher in npm: ${npmWebUrl(packageName)}`] : []),
731
+ "Enable account-level 2FA, allow staged publishing only, then trigger the workflow.",
732
+ ...(!coreFirst ? ["Publish the internal core dependency at a compatible version before staging this package."] : []),
733
+ "Review with npm stage list/view/download and approve the chosen stage with 2FA.",
734
+ ];
735
+ return {
736
+ root,
737
+ ready: Boolean(
738
+ packageName && exists && repository && workflow && lockfile && node && npm && trustedPublisher === "verified" && coreFirst,
739
+ ),
740
+ packageName,
741
+ repository,
742
+ workflowPath,
743
+ checks: {
744
+ packageExists: exists,
745
+ repository: Boolean(repository),
746
+ workflow,
747
+ lockfile,
748
+ node,
749
+ npm,
750
+ trustedPublisher,
751
+ coreFirst,
752
+ loggedIn,
753
+ },
754
+ diagnostics,
755
+ nextSteps,
756
+ };
757
+ }
758
+
759
+ /** Local, informative mirror of the CI-side ordering guard: every internal
760
+ * (workspace-sibling) dependency must already be published on npm at a
761
+ * version its declared range accepts. Empty when there are none. */
762
+ private async coreFirstStatus(internal: Record<string, string>, diagnostics: Diagnostic[]): Promise<boolean> {
763
+ let ok = true;
764
+ for (const [name, range] of Object.entries(internal)) {
765
+ let latest: string | undefined;
766
+ try {
767
+ latest = (await this.registry.info(name)).version;
768
+ } catch {
769
+ latest = undefined;
770
+ }
771
+ if (!latest) {
772
+ diagnostics.push(
773
+ diagnostic(
774
+ "PUBLISH_DEPENDENCY_NOT_PUBLISHED",
775
+ "error",
776
+ "package.json#dependencies",
777
+ `${name} must be published on npm before staging this package (core-first ordering)`,
778
+ ),
779
+ );
780
+ ok = false;
781
+ continue;
782
+ }
783
+ const satisfies = satisfiesRange(latest, range);
784
+ if (satisfies === false) {
785
+ diagnostics.push(
786
+ diagnostic(
787
+ "PUBLISH_DEPENDENCY_RANGE_MISMATCH",
788
+ "error",
789
+ "package.json#dependencies",
790
+ `${name}@${range} does not accept npm's published ${latest}; align the range or publish a compatible core release first`,
791
+ ),
792
+ );
793
+ ok = false;
794
+ } else if (satisfies === undefined) {
795
+ diagnostics.push(
796
+ diagnostic(
797
+ "PUBLISH_DEPENDENCY_RANGE_UNKNOWN",
798
+ "warning",
799
+ "package.json#dependencies",
800
+ `could not evaluate whether ${name}@${range} accepts npm's published ${latest}`,
801
+ ),
802
+ );
803
+ }
804
+ }
805
+ return ok;
806
+ }
807
+
808
+ private async trustedPublisherStatus(
809
+ packageName: string,
810
+ workflowFile: string,
811
+ repository: string,
812
+ ): Promise<"verified" | "not-verified" | "unknown"> {
813
+ let result: VersionCommandResult;
814
+ try {
815
+ result = await this.trustStatusCommand(packageName);
816
+ } catch {
817
+ return "unknown";
818
+ }
819
+ if (result.code !== 0) return "unknown";
820
+ if (result.stdout.trim() === "") return "not-verified";
821
+ try {
822
+ const value = JSON.parse(result.stdout) as Record<string, unknown>;
823
+ const permissions = Array.isArray(value.permissions)
824
+ ? value.permissions.filter((item): item is string => typeof item === "string")
825
+ : [];
826
+ const matches =
827
+ value.type === "github" &&
828
+ value.repository === repository &&
829
+ value.file === workflowFile &&
830
+ permissions.includes("createStagedPackage") &&
831
+ !permissions.includes("createPackage");
832
+ return matches ? "verified" : "not-verified";
833
+ } catch {
834
+ return "unknown";
835
+ }
836
+ }
837
+ }
838
+
839
+ export function formatPublishReport(report: PublishSetupReport | PublishStatusReport, json = false): string {
840
+ if (json) return `${JSON.stringify(report)}\n`;
841
+ if ("wrote" in report) {
842
+ let out = `${report.ok ? "ready" : "not ready"}: ${report.packageName ?? report.root}\n`;
843
+ if (report.wrote) out += `wrote ${report.workflowPath}\n`;
844
+ for (const item of report.diagnostics)
845
+ out += `${item.severity} ${item.code}: ${item.message}${item.fix ? `\n fix: ${item.fix}` : ""}\n`;
846
+ if (report.trustCommand) out += `next: ${report.trustCommand}\n`;
847
+ if (report.webUrl) out += `web: ${report.webUrl}\n`;
848
+ return out.slice(0, 16 * 1024);
849
+ }
850
+ let out = `${report.ready ? "ready" : "not ready"}: ${report.packageName ?? report.root}\n`;
851
+ for (const [name, value] of Object.entries(report.checks)) out += `${name}: ${String(value)}\n`;
852
+ for (const item of report.diagnostics) out += `${item.severity} ${item.code}: ${item.message}${item.fix ? `\n fix: ${item.fix}` : ""}\n`;
853
+ for (const step of report.nextSteps) out += `${step}\n`;
854
+ return out.slice(0, 16 * 1024);
855
+ }