@phyzess/pappra 0.1.0-beta.2 → 0.1.0-beta.3

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.
@@ -12,6 +12,8 @@ export interface HostTargetOptions {
12
12
  readonly hostRoot?: string;
13
13
  readonly runtimeEntry?: string;
14
14
  readonly hostVersion?: string | null;
15
+ /** Rewrites a manifest previously pinned to a package checkout path. */
16
+ readonly migrate?: boolean;
15
17
  }
16
18
  export interface HostPlanBundle {
17
19
  readonly plan: HostIntegrationPlan;
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { link, mkdir, open, realpath, rename, rm } from 'node:fs/promises';
3
- import { dirname, join, resolve } from 'node:path';
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { createDiagnostic } from '../../schemas/diagnostics.js';
6
6
  import { diagnosticCodes } from '../../schemas/errors.js';
@@ -26,7 +26,20 @@ function now() {
26
26
  }
27
27
  function packageRuntimeEntry() {
28
28
  const packageJson = fileURLToPath(import.meta.resolve('@phyzess/pappra/package.json'));
29
- return join(dirname(packageJson), 'dist', 'hosts', 'integration', 'hook-entry.js');
29
+ return join(dirname(packageJson), 'dist', 'hosts', 'integration', 'pappra-hook.mjs');
30
+ }
31
+ /**
32
+ * Uses the host's project placeholder when the hook copy lives inside the
33
+ * workspace, so a moved checkout or an evicted npx cache cannot break the hook.
34
+ */
35
+ function hookTarget(host, workspaceRoot, hostRoot, hookSource) {
36
+ const installedPath = join(hostRoot, 'hooks', 'pappra-hook.mjs');
37
+ const relativePath = relative(workspaceRoot, installedPath);
38
+ const insideWorkspace = !relativePath.startsWith('..') && !isAbsolute(relativePath);
39
+ const expression = host === 'claude' && insideWorkspace
40
+ ? `\${CLAUDE_PROJECT_DIR}/${relativePath.split(sep).join('/')}`
41
+ : installedPath;
42
+ return { installedPath, expression, source: hookSource };
30
43
  }
31
44
  function diagnostic(code, message, source, hint) {
32
45
  return createDiagnostic({
@@ -211,7 +224,7 @@ function isExactManagedHook(value, commands) {
211
224
  typeof value.command === 'string' &&
212
225
  commands.has(value.command));
213
226
  }
214
- function managedConfig(host, existing, runtimeEntry, path) {
227
+ function managedConfig(host, existing, hookExpression, path) {
215
228
  let base = {};
216
229
  if (existing !== null) {
217
230
  const parsed = parseJsonObject(existing, path);
@@ -219,7 +232,7 @@ function managedConfig(host, existing, runtimeEntry, path) {
219
232
  return parsed;
220
233
  base = parsed.value;
221
234
  }
222
- const patch = renderHookConfigPatch(host, runtimeEntry);
235
+ const patch = renderHookConfigPatch(host, hookExpression);
223
236
  const rawHooks = base['hooks'];
224
237
  if (rawHooks !== undefined &&
225
238
  (typeof rawHooks !== 'object' ||
@@ -271,11 +284,11 @@ function managedConfig(host, existing, runtimeEntry, path) {
271
284
  bytes: Buffer.from(`${JSON.stringify(next, null, 2)}\n`),
272
285
  };
273
286
  }
274
- function validateManifestIdentity(manifest, host, hostRoot, configPath, runtimeEntry) {
287
+ function validateManifestIdentity(manifest, host, hostRoot, configPath, hookExpression, migrate) {
275
288
  if (manifest.host !== host ||
276
289
  resolve(manifest.hostRoot) !== hostRoot ||
277
290
  manifest.configPath !== configPath ||
278
- resolve(manifest.runtimeEntry) !== runtimeEntry) {
291
+ (!migrate && manifest.runtimeEntry !== hookExpression)) {
279
292
  return diagnostic(diagnosticCodes.host_install_conflict, `Host manifest identity does not match the selected target: ${hostRoot}`, join(hostRoot, BUNDLE_FILE), 'Inspect the manifest and target; Pappra does not adopt a foreign installation.');
280
293
  }
281
294
  const seen = new Set();
@@ -290,11 +303,11 @@ function validateManifestIdentity(manifest, host, hostRoot, configPath, runtimeE
290
303
  }
291
304
  return null;
292
305
  }
293
- function configHasExpectedHooks(host, bytes, runtimeEntry, path) {
306
+ function configHasExpectedHooks(host, bytes, hookExpression, path) {
294
307
  const parsed = parseJsonObject(bytes, path);
295
308
  if (!parsed.ok)
296
309
  return false;
297
- const patch = renderHookConfigPatch(host, runtimeEntry);
310
+ const patch = renderHookConfigPatch(host, hookExpression);
298
311
  const hooks = parsed.value['hooks'];
299
312
  if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) {
300
313
  return false;
@@ -329,7 +342,7 @@ function configHasExpectedHooks(host, bytes, runtimeEntry, path) {
329
342
  }
330
343
  return true;
331
344
  }
332
- function bundleManifestContent(bundle, hostRoot, runtimeEntry, configPath, files, observedAt) {
345
+ function bundleManifestContent(bundle, hostRoot, hookExpression, configPath, files, observedAt) {
333
346
  return Buffer.from(`${JSON.stringify({
334
347
  schema: 'pappra/host-install/1',
335
348
  host: bundle.host,
@@ -337,7 +350,7 @@ function bundleManifestContent(bundle, hostRoot, runtimeEntry, configPath, files
337
350
  bundleDigest: bundle.bundleDigest,
338
351
  installedAt: observedAt,
339
352
  hostRoot,
340
- runtimeEntry,
353
+ runtimeEntry: hookExpression,
341
354
  configPath,
342
355
  files: files.map((file) => ({
343
356
  path: file.path,
@@ -352,11 +365,13 @@ function bundleManifestContent(bundle, hostRoot, runtimeEntry, configPath, files
352
365
  trustRequired: true,
353
366
  }, null, 2)}\n`);
354
367
  }
355
- async function hostPlanFiles(host, hostRoot, runtimeEntry, observedAt) {
368
+ async function hostPlanFiles(host, workspaceRoot, hostRoot, hookSource, observedAt, migrate = false) {
369
+ const target = hookTarget(host, workspaceRoot, hostRoot, hookSource);
356
370
  let payload;
357
371
  try {
358
372
  payload = await loadHostBundle(host, getPackageInfo().version, {
359
- runtimeEntry,
373
+ hookSource,
374
+ hookExpression: target.expression,
360
375
  observedAt,
361
376
  });
362
377
  }
@@ -401,7 +416,7 @@ async function hostPlanFiles(host, hostRoot, runtimeEntry, observedAt) {
401
416
  diagnostic: diagnostic(diagnosticCodes.host_install_conflict, `Host manifest is invalid and was preserved: ${manifestPath}`, manifestPath, 'Fix or remove the manifest explicitly; no managed file was overwritten.'),
402
417
  };
403
418
  }
404
- const identityError = validateManifestIdentity(parsed.data, host, hostRoot, hostConfigRelativePath(host), runtimeEntry);
419
+ const identityError = validateManifestIdentity(parsed.data, host, hostRoot, hostConfigRelativePath(host), target.expression, migrate);
405
420
  if (identityError !== null) {
406
421
  return { ok: false, conflict: true, diagnostic: identityError };
407
422
  }
@@ -477,7 +492,7 @@ async function hostPlanFiles(host, hostRoot, runtimeEntry, observedAt) {
477
492
  };
478
493
  }
479
494
  const before = existing.ok ? existing.value.bytes : null;
480
- const merged = managedConfig(host === 'codex' ? 'codex' : 'claude', before, runtimeEntry, configPath);
495
+ const merged = managedConfig(host === 'codex' ? 'codex' : 'claude', before, target.expression, configPath);
481
496
  if (!merged.ok) {
482
497
  return {
483
498
  ...merged,
@@ -510,7 +525,7 @@ async function hostPlanFiles(host, hostRoot, runtimeEntry, observedAt) {
510
525
  })) {
511
526
  installedAt = installedManifest.installedAt;
512
527
  }
513
- const manifest = bundleManifestContent(payload.bundle, hostRoot, runtimeEntry, hostConfigRelativePath(host), files, installedAt);
528
+ const manifest = bundleManifestContent(payload.bundle, hostRoot, target.expression, hostConfigRelativePath(host), files, installedAt);
514
529
  contents.set(BUNDLE_FILE, manifest);
515
530
  files.push({
516
531
  path: BUNDLE_FILE,
@@ -527,14 +542,14 @@ async function hostPlanFiles(host, hostRoot, runtimeEntry, observedAt) {
527
542
  configPath,
528
543
  };
529
544
  }
530
- function planFromFiles(host, workspaceRoot, hostRoot, runtimeEntry, bundleDigest, files) {
545
+ function planFromFiles(host, workspaceRoot, hostRoot, hookExpression, bundleDigest, files) {
531
546
  return hostIntegrationPlanSchema.parse({
532
547
  schema: 'pappra/host-integration/1',
533
548
  planId: randomUUID(),
534
549
  host,
535
550
  workspaceRoot,
536
551
  hostRoot,
537
- runtimeEntry,
552
+ runtimeEntry: hookExpression,
538
553
  bundleDigest,
539
554
  files,
540
555
  reloadRequired: true,
@@ -854,25 +869,26 @@ export class HostIntegrationService {
854
869
  }
855
870
  async plan(host, options = {}) {
856
871
  const hostRoot = resolve(options.hostRoot ?? join(this.#root, '.pappra', 'local', 'hosts', host));
857
- const runtimeEntry = resolve(options.runtimeEntry ?? this.#runtimeEntry);
872
+ const hookSource = resolve(options.runtimeEntry ?? this.#runtimeEntry);
858
873
  const version = options.hostVersion ?? this.#hostVersion;
859
874
  if (!isSupportedVersion(host, version)) {
860
875
  throw new Error(`Host ${host} version ${version ?? '(unknown)'} is outside the pinned support matrix.`);
861
876
  }
862
- if (!(await runtimeEntryReady(runtimeEntry))) {
863
- throw new Error(`Fixed Pappra runtime entry is missing: ${runtimeEntry}`);
877
+ if (!(await runtimeEntryReady(hookSource))) {
878
+ throw new Error(`Bundled Pappra hook is missing: ${hookSource}`);
864
879
  }
865
- const built = await hostPlanFiles(host, hostRoot, runtimeEntry, this.#observedAt);
880
+ const target = hookTarget(host, this.#root, hostRoot, hookSource);
881
+ const built = await hostPlanFiles(host, this.#root, hostRoot, hookSource, this.#observedAt, options.migrate === true);
866
882
  if (!built.ok)
867
883
  throw new Error(built.diagnostic.message);
868
- return planFromFiles(host, this.#root, hostRoot, runtimeEntry, built.bundle.bundleDigest, built.files);
884
+ return planFromFiles(host, this.#root, hostRoot, target.expression, built.bundle.bundleDigest, built.files);
869
885
  }
870
886
  async install(host, options) {
871
887
  let plan;
872
888
  let contents;
873
889
  try {
874
890
  const hostRoot = resolve(options.hostRoot ?? join(this.#root, '.pappra', 'local', 'hosts', host));
875
- const runtimeEntry = resolve(options.runtimeEntry ?? this.#runtimeEntry);
891
+ const hookSource = resolve(options.runtimeEntry ?? this.#runtimeEntry);
876
892
  const version = options.hostVersion ?? this.#hostVersion;
877
893
  if (!isSupportedVersion(host, version)) {
878
894
  return hostIntegrationResultSchema.parse({
@@ -887,7 +903,7 @@ export class HostIntegrationService {
887
903
  ],
888
904
  });
889
905
  }
890
- if (!(await runtimeEntryReady(runtimeEntry))) {
906
+ if (!(await runtimeEntryReady(hookSource))) {
891
907
  return hostIntegrationResultSchema.parse({
892
908
  schema: 'pappra/host-integration/1',
893
909
  ok: false,
@@ -896,11 +912,12 @@ export class HostIntegrationService {
896
912
  appliedFiles: [],
897
913
  residualPaths: [],
898
914
  diagnostics: [
899
- diagnostic(diagnosticCodes.host_unavailable, `Fixed Pappra runtime entry is missing: ${runtimeEntry}`, runtimeEntry, 'Install or select a complete Pappra runtime before wiring a host.'),
915
+ diagnostic(diagnosticCodes.host_unavailable, `Bundled Pappra hook is missing: ${hookSource}`, hookSource, 'Install or select a complete Pappra runtime before wiring a host.'),
900
916
  ],
901
917
  });
902
918
  }
903
- const built = await hostPlanFiles(host, hostRoot, runtimeEntry, this.#observedAt);
919
+ const target = hookTarget(host, this.#root, hostRoot, hookSource);
920
+ const built = await hostPlanFiles(host, this.#root, hostRoot, hookSource, this.#observedAt, options.migrate === true);
904
921
  if (!built.ok) {
905
922
  return hostIntegrationResultSchema.parse({
906
923
  schema: 'pappra/host-integration/1',
@@ -912,7 +929,7 @@ export class HostIntegrationService {
912
929
  diagnostics: [built.diagnostic],
913
930
  });
914
931
  }
915
- plan = planFromFiles(host, this.#root, hostRoot, runtimeEntry, built.bundle.bundleDigest, built.files);
932
+ plan = planFromFiles(host, this.#root, hostRoot, target.expression, built.bundle.bundleDigest, built.files);
916
933
  contents = built.contents;
917
934
  }
918
935
  catch (error) {
@@ -1138,7 +1155,8 @@ export class HostIntegrationService {
1138
1155
  });
1139
1156
  }
1140
1157
  const expectedConfigPath = hostConfigRelativePath(host);
1141
- const identityError = validateManifestIdentity(manifest, host, hostRoot, expectedConfigPath, this.#runtimeEntry);
1158
+ const target = hookTarget(host, this.#root, hostRoot, this.#runtimeEntry);
1159
+ const identityError = validateManifestIdentity(manifest, host, hostRoot, expectedConfigPath, target.expression, false);
1142
1160
  if (identityError !== null) {
1143
1161
  return hostVerificationSchema.parse({
1144
1162
  schema: 'pappra/host-verification/1',
@@ -1162,7 +1180,8 @@ export class HostIntegrationService {
1162
1180
  let currentBundle;
1163
1181
  try {
1164
1182
  currentBundle = await loadHostBundle(host, getPackageInfo().version, {
1165
- runtimeEntry: this.#runtimeEntry,
1183
+ hookSource: this.#runtimeEntry,
1184
+ hookExpression: target.expression,
1166
1185
  observedAt: this.#observedAt,
1167
1186
  });
1168
1187
  }
@@ -1306,7 +1325,7 @@ export class HostIntegrationService {
1306
1325
  const config = await readFileBounded(configPath, DEFAULT_MAX_CONFIG_BYTES);
1307
1326
  if (!config.ok ||
1308
1327
  manifestFiles.get(manifest.configPath) !== config.value.digest ||
1309
- !configHasExpectedHooks(host === 'codex' ? 'codex' : 'claude', config.value.bytes, this.#runtimeEntry, configPath)) {
1328
+ !configHasExpectedHooks(host === 'codex' ? 'codex' : 'claude', config.value.bytes, target.expression, configPath)) {
1310
1329
  status = 'conflict';
1311
1330
  checks.push({
1312
1331
  id: 'host.config',
@@ -5,7 +5,7 @@ export interface RenderedHostTemplate {
5
5
  readonly bytes: Buffer;
6
6
  readonly kind: 'extension' | 'hook';
7
7
  }
8
- export declare function renderHookCommand(host: HostName, event: HostLifecycleEvent, runtimeEntry: string): string;
8
+ export declare function renderHookCommand(host: HostName, event: HostLifecycleEvent, hookExpression: string): string;
9
9
  interface HookEntry {
10
10
  readonly hooks: readonly {
11
11
  readonly type: 'command';
@@ -13,10 +13,10 @@ interface HookEntry {
13
13
  readonly timeout: number;
14
14
  }[];
15
15
  }
16
- export declare function renderHookConfigPatch(host: 'codex' | 'claude', runtimeEntry: string): {
16
+ export declare function renderHookConfigPatch(host: 'codex' | 'claude', hookExpression: string): {
17
17
  readonly hooks: Record<string, readonly HookEntry[]>;
18
18
  };
19
- export declare function renderHostTemplate(host: HostName, runtimeEntry: string): RenderedHostTemplate;
19
+ export declare function renderHostTemplate(host: HostName, hookExpression: string): RenderedHostTemplate;
20
20
  export declare function hostConfigRelativePath(host: HostName): 'settings.json' | 'hooks.json' | null;
21
21
  export declare function hostConfigName(host: HostName): string;
22
22
  export {};
@@ -1,18 +1,24 @@
1
- import { pathToFileURL } from 'node:url';
2
- import { basename, dirname } from 'node:path';
1
+ import { basename } from 'node:path';
3
2
  export const PAPPRA_MANAGED_MARKER = 'pappra-managed';
4
3
  function shellQuote(value) {
5
4
  return `'${value.replaceAll("'", "'\\''")}'`;
6
5
  }
7
- export function renderHookCommand(host, event, runtimeEntry) {
8
- return `${shellQuote(process.execPath)} ${shellQuote(runtimeEntry)} ${shellQuote(host)} ${shellQuote(event)} # ${PAPPRA_MANAGED_MARKER}`;
6
+ /**
7
+ * Host placeholders such as ${CLAUDE_PROJECT_DIR} only expand inside double
8
+ * quotes; absolute paths are single-quoted to survive spaces and quotes.
9
+ */
10
+ function pathExpression(value) {
11
+ return value.includes('${') ? `"${value}"` : shellQuote(value);
12
+ }
13
+ export function renderHookCommand(host, event, hookExpression) {
14
+ return `${shellQuote(process.execPath)} ${pathExpression(hookExpression)} ${shellQuote(host)} ${shellQuote(event)} # ${PAPPRA_MANAGED_MARKER}`;
9
15
  }
10
- export function renderHookConfigPatch(host, runtimeEntry) {
16
+ export function renderHookConfigPatch(host, hookExpression) {
11
17
  const entry = (event) => ({
12
18
  hooks: [
13
19
  {
14
20
  type: 'command',
15
- command: renderHookCommand(host, event, runtimeEntry),
21
+ command: renderHookCommand(host, event, hookExpression),
16
22
  timeout: 1,
17
23
  },
18
24
  ],
@@ -30,15 +36,14 @@ export function renderHookConfigPatch(host, runtimeEntry) {
30
36
  },
31
37
  };
32
38
  }
33
- export function renderHostTemplate(host, runtimeEntry) {
39
+ export function renderHostTemplate(host, hookExpression) {
34
40
  if (host === 'pi') {
35
- const runtimeUrl = pathToFileURL(`${dirname(runtimeEntry)}/pi-extension-runtime.js`).href;
36
41
  const source = `/**
37
42
  * Generated Pappra Pi extension. It registers bounded local lifecycle handlers;
38
43
  * it does not start processes, models, network requests or background work.
39
44
  */
40
45
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
41
- import { createPiExtension } from ${JSON.stringify(runtimeUrl)};
46
+ import { createPiExtension } from "./runtime.mjs";
42
47
 
43
48
  export default function (pi: ExtensionAPI): void {
44
49
  createPiExtension(pi);
@@ -50,7 +55,7 @@ export default function (pi: ExtensionAPI): void {
50
55
  kind: 'extension',
51
56
  };
52
57
  }
53
- const patch = renderHookConfigPatch(host, runtimeEntry);
58
+ const patch = renderHookConfigPatch(host, hookExpression);
54
59
  return {
55
60
  path: `templates/${host}/hooks.json`,
56
61
  bytes: Buffer.from(`${JSON.stringify(patch, null, 2)}\n`),