@phyzess/pappra 0.1.0-beta.2 → 0.1.0-beta.4
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/README.md +24 -7
- package/dist/app/doctor.js +2 -2
- package/dist/app/init.d.ts +28 -0
- package/dist/app/init.js +419 -88
- package/dist/cli/commands/docs.d.ts +1 -0
- package/dist/cli/commands/docs.js +104 -3
- package/dist/cli/commands/host.d.ts +3 -1
- package/dist/cli/commands/host.js +50 -2
- package/dist/cli/commands/init.d.ts +8 -0
- package/dist/cli/commands/init.js +186 -9
- package/dist/cli/commands/runtime.d.ts +14 -0
- package/dist/cli/commands/runtime.js +263 -0
- package/dist/cli/run.js +134 -7
- package/dist/hosts/detect.d.ts +1 -0
- package/dist/hosts/detect.js +1 -1
- package/dist/hosts/integration/bundle-export.d.ts +22 -0
- package/dist/hosts/integration/bundle-export.js +114 -0
- package/dist/hosts/integration/bundle.d.ts +4 -1
- package/dist/hosts/integration/bundle.js +20 -2
- package/dist/hosts/integration/pappra-extension-runtime.mjs +24605 -0
- package/dist/hosts/integration/pappra-hook.mjs +24723 -0
- package/dist/hosts/integration/service.d.ts +8 -0
- package/dist/hosts/integration/service.js +62 -30
- package/dist/hosts/integration/templates.d.ts +3 -3
- package/dist/hosts/integration/templates.js +15 -10
- package/dist/hosts/pilot.d.ts +77 -77
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/install/runtime.d.ts +1 -1
- package/dist/install/runtime.js +117 -10
- package/dist/package-info.d.ts +2 -0
- package/dist/package-info.js +6 -0
- package/dist/schemas/host.js +4 -2
- package/dist/schemas/install.d.ts +14 -0
- package/dist/schemas/install.js +8 -0
- package/package.json +8 -2
package/dist/app/init.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
-
import { mkdir, open, readFile, rm, rmdir } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, open, readFile, rename, rm, rmdir, writeFile, } from 'node:fs/promises';
|
|
4
4
|
import { createDiagnostic, hasErrors, pointer, } from '../schemas/diagnostics.js';
|
|
5
5
|
import { diagnosticCodes } from '../schemas/errors.js';
|
|
6
|
-
import { initPlanSchema, parseInitChoices, } from '../schemas/init.js';
|
|
6
|
+
import { INIT_CHOICES_SCHEMA, initPlanSchema, parseInitChoices, } from '../schemas/init.js';
|
|
7
7
|
import { machineConfigSchema } from '../schemas/machine.js';
|
|
8
8
|
import { isExternalRepositoryPath, isPathInside, isPortableRelativePath, normalizePortableRelativePath, resolvePortableRelativePath, samePath, } from '../schemas/paths.js';
|
|
9
9
|
import { WORKSPACE_SCHEMA, validateWorkspaceConfigSemantics, workspaceConfigSchema, } from '../schemas/workspace.js';
|
|
10
10
|
import { getPackageInfo } from '../package-info.js';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { executableOnPath } from '../hosts/detect.js';
|
|
13
|
+
import { HOST_SUPPORT } from '../hosts/integration/support.js';
|
|
11
14
|
import { observePath, resolveRealpath, sha256Digest } from '../infra/fs.js';
|
|
12
15
|
import { inspectMachineConfig, inspectWorkspaceConfig, } from '../workspace/config.js';
|
|
13
16
|
import { discoverWorkspace, } from '../workspace/discover.js';
|
|
14
17
|
import { resolveWorkspaceRoot } from '../workspace/resolve.js';
|
|
15
18
|
import { buildInitPlan } from '../install/plan.js';
|
|
19
|
+
import { backupPath, ensureStatePathBoundary, readWorkspaceManifest, writeWorkspaceManifest, } from '../install/ownership.js';
|
|
16
20
|
import { beginManagedApply, completeManagedApply, failManagedApply, recordManagedFile, } from '../install/apply.js';
|
|
17
21
|
const workspaceConfigRelativePath = '.pappra/workspace.json';
|
|
18
22
|
const machineConfigRelativePath = '.pappra/local/machine.json';
|
|
@@ -239,6 +243,164 @@ export function listKnowledgeCandidates(discovery) {
|
|
|
239
243
|
}
|
|
240
244
|
return [...candidates.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
241
245
|
}
|
|
246
|
+
const derivedExcludes = [
|
|
247
|
+
'node_modules',
|
|
248
|
+
'dist',
|
|
249
|
+
'coverage',
|
|
250
|
+
'tmp',
|
|
251
|
+
'test-results',
|
|
252
|
+
];
|
|
253
|
+
const derivedValidationScripts = ['lint', 'typecheck', 'test'];
|
|
254
|
+
function detectedHostVersion(command, pathEnv) {
|
|
255
|
+
const executable = executableOnPath(command, pathEnv);
|
|
256
|
+
if (executable === null)
|
|
257
|
+
return null;
|
|
258
|
+
const probe = spawnSync(executable, ['--version'], {
|
|
259
|
+
encoding: 'utf8',
|
|
260
|
+
shell: false,
|
|
261
|
+
timeout: 3_000,
|
|
262
|
+
maxBuffer: 256 * 1024,
|
|
263
|
+
});
|
|
264
|
+
if (probe.error !== undefined || probe.status !== 0)
|
|
265
|
+
return null;
|
|
266
|
+
const output = `${probe.stdout ?? ''}\n${probe.stderr ?? ''}`;
|
|
267
|
+
const match = /(\d+\.\d+\.\d+)/.exec(output);
|
|
268
|
+
return match?.[1] ?? null;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Derives deterministic init selections from discovery alone. Every value is
|
|
272
|
+
* reported with the rule that produced it so a caller can audit the choice
|
|
273
|
+
* before applying it; nothing here writes, installs or prompts.
|
|
274
|
+
*/
|
|
275
|
+
export async function deriveInitChoices(discovery, options = {}) {
|
|
276
|
+
const pathEnv = options.pathEnv ?? process.env['PATH'] ?? '';
|
|
277
|
+
const rules = [];
|
|
278
|
+
const repositories = discovery.repositories.map((repository) => {
|
|
279
|
+
const path = repositoryKey(repository);
|
|
280
|
+
const access = samePath(repository.root, discovery.root)
|
|
281
|
+
? 'write'
|
|
282
|
+
: 'read';
|
|
283
|
+
rules.push({
|
|
284
|
+
field: 'repositories',
|
|
285
|
+
value: path,
|
|
286
|
+
reason: access === 'write'
|
|
287
|
+
? 'workspace-root checkout is selected for write access'
|
|
288
|
+
: 'discovered checkout outside the workspace root is read-only',
|
|
289
|
+
});
|
|
290
|
+
return { path, access };
|
|
291
|
+
});
|
|
292
|
+
const knowledgeCandidates = listKnowledgeCandidates(discovery);
|
|
293
|
+
const rootRuleFiles = ['AGENTS.md', 'CLAUDE.md'];
|
|
294
|
+
const knowledgeSources = [];
|
|
295
|
+
for (const name of rootRuleFiles) {
|
|
296
|
+
const candidate = knowledgeCandidates.find((entry) => entry.path.toLowerCase() === name.toLowerCase());
|
|
297
|
+
if (candidate === undefined)
|
|
298
|
+
continue;
|
|
299
|
+
knowledgeSources.push(candidate.path);
|
|
300
|
+
rules.push({
|
|
301
|
+
field: 'knowledge.sources',
|
|
302
|
+
value: candidate.path,
|
|
303
|
+
reason: 'workspace-root rule file reported by discovery',
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const docs = knowledgeCandidates.find((entry) => entry.path === 'docs');
|
|
307
|
+
if (docs !== undefined) {
|
|
308
|
+
knowledgeSources.push(docs.path);
|
|
309
|
+
rules.push({
|
|
310
|
+
field: 'knowledge.sources',
|
|
311
|
+
value: docs.path,
|
|
312
|
+
reason: 'documentation directory reported by discovery',
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
const codegraphDetected = executableOnPath('codegraph', pathEnv) !== null &&
|
|
316
|
+
(await Promise.all(discovery.repositories.map(async (repository) => observePath(join(repository.root, '.codegraph'))))).some((observation) => observation.kind !== 'missing');
|
|
317
|
+
const graph = codegraphDetected ? 'codegraph' : 'none';
|
|
318
|
+
rules.push({
|
|
319
|
+
field: 'code.graph',
|
|
320
|
+
value: graph,
|
|
321
|
+
reason: codegraphDetected
|
|
322
|
+
? 'codegraph executable and .codegraph index were both observed'
|
|
323
|
+
: 'no codegraph executable plus .codegraph index pair was observed',
|
|
324
|
+
});
|
|
325
|
+
rules.push({
|
|
326
|
+
field: 'code.symbols',
|
|
327
|
+
value: 'auto',
|
|
328
|
+
reason: 'symbol providers are detected passively at use time',
|
|
329
|
+
});
|
|
330
|
+
rules.push({
|
|
331
|
+
field: 'code.prepare',
|
|
332
|
+
value: 'on-demand',
|
|
333
|
+
reason: 'index preparation stays an explicit, bounded action',
|
|
334
|
+
});
|
|
335
|
+
const validation = [];
|
|
336
|
+
for (const script of derivedValidationScripts) {
|
|
337
|
+
const candidate = discovery.validationCandidates.find((entry) => entry.id.endsWith(`:${script}`) && !validation.includes(entry.id));
|
|
338
|
+
if (candidate === undefined)
|
|
339
|
+
continue;
|
|
340
|
+
validation.push(candidate.id);
|
|
341
|
+
rules.push({
|
|
342
|
+
field: 'validation',
|
|
343
|
+
value: candidate.id,
|
|
344
|
+
reason: `package script "${script}" reported by discovery`,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
rules.push({
|
|
348
|
+
field: 'excludes',
|
|
349
|
+
value: derivedExcludes.join(', '),
|
|
350
|
+
reason: 'built-in defaults keep generated and dependency trees out of scope',
|
|
351
|
+
});
|
|
352
|
+
const hosts = [];
|
|
353
|
+
for (const host of ['claude', 'codex', 'pi']) {
|
|
354
|
+
const command = host;
|
|
355
|
+
const version = detectedHostVersion(command, pathEnv);
|
|
356
|
+
if (version === null || version !== HOST_SUPPORT[host].version) {
|
|
357
|
+
rules.push({
|
|
358
|
+
field: 'hosts',
|
|
359
|
+
value: host,
|
|
360
|
+
reason: version === null
|
|
361
|
+
? 'host executable was not observed on PATH'
|
|
362
|
+
: `observed version ${version} is outside the pinned support matrix`,
|
|
363
|
+
});
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
hosts.push(host);
|
|
367
|
+
rules.push({
|
|
368
|
+
field: 'hosts',
|
|
369
|
+
value: host,
|
|
370
|
+
reason: `host executable reports the pinned version ${version}`,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
const name = basename(discovery.root);
|
|
374
|
+
rules.push({
|
|
375
|
+
field: 'name',
|
|
376
|
+
value: name,
|
|
377
|
+
reason: 'workspace name derived from the workspace root directory',
|
|
378
|
+
});
|
|
379
|
+
const choices = {
|
|
380
|
+
schema: INIT_CHOICES_SCHEMA,
|
|
381
|
+
name,
|
|
382
|
+
repositories,
|
|
383
|
+
knowledge: {
|
|
384
|
+
sources: knowledgeSources,
|
|
385
|
+
newDocuments: '.pappra/knowledge',
|
|
386
|
+
taskVisibility: 'private',
|
|
387
|
+
},
|
|
388
|
+
code: { graph, symbols: 'auto', prepare: 'on-demand' },
|
|
389
|
+
validation,
|
|
390
|
+
excludes: [...derivedExcludes],
|
|
391
|
+
hosts,
|
|
392
|
+
};
|
|
393
|
+
return {
|
|
394
|
+
choices,
|
|
395
|
+
rules,
|
|
396
|
+
candidates: {
|
|
397
|
+
repositories: listRepositoryCandidates(discovery).map((candidate) => candidate.key),
|
|
398
|
+
knowledge: knowledgeCandidates.map((candidate) => candidate.path),
|
|
399
|
+
validation: discovery.validationCandidates.map((candidate) => candidate.id),
|
|
400
|
+
hosts,
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
}
|
|
242
404
|
function selectRepositories(choices, discovery, root) {
|
|
243
405
|
const diagnostics = [];
|
|
244
406
|
const byKey = new Map();
|
|
@@ -505,22 +667,38 @@ function scopeRepositories(config, discovery) {
|
|
|
505
667
|
};
|
|
506
668
|
});
|
|
507
669
|
}
|
|
508
|
-
function planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig) {
|
|
670
|
+
function planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig, existing) {
|
|
509
671
|
const workspaceBytes = needWorkspaceConfig ? jsonBytes(config) : null;
|
|
510
672
|
const machineBytes = needMachineConfig ? jsonBytes(machine) : null;
|
|
511
673
|
const files = [];
|
|
512
|
-
if (workspaceBytes !== null
|
|
674
|
+
if (workspaceBytes !== null &&
|
|
675
|
+
existing.workspaceDigest !== sha256Digest(workspaceBytes)) {
|
|
513
676
|
files.push({
|
|
514
677
|
path: workspaceConfigRelativePath,
|
|
515
678
|
bytes: workspaceBytes,
|
|
516
679
|
beforeText: null,
|
|
680
|
+
...(existing.workspaceDigest === null
|
|
681
|
+
? {}
|
|
682
|
+
: {
|
|
683
|
+
operation: 'replace-managed',
|
|
684
|
+
expectedBeforeDigest: existing.workspaceDigest,
|
|
685
|
+
backupRequired: true,
|
|
686
|
+
}),
|
|
517
687
|
});
|
|
518
688
|
}
|
|
519
|
-
if (machineBytes !== null
|
|
689
|
+
if (machineBytes !== null &&
|
|
690
|
+
existing.machineDigest !== sha256Digest(machineBytes)) {
|
|
520
691
|
files.push({
|
|
521
692
|
path: machineConfigRelativePath,
|
|
522
693
|
bytes: machineBytes,
|
|
523
694
|
beforeText: null,
|
|
695
|
+
...(existing.machineDigest === null
|
|
696
|
+
? {}
|
|
697
|
+
: {
|
|
698
|
+
operation: 'replace-managed',
|
|
699
|
+
expectedBeforeDigest: existing.machineDigest,
|
|
700
|
+
backupRequired: true,
|
|
701
|
+
}),
|
|
524
702
|
});
|
|
525
703
|
}
|
|
526
704
|
return { files, workspaceBytes, machineBytes };
|
|
@@ -626,14 +804,16 @@ export async function planWorkspaceInit(options) {
|
|
|
626
804
|
const existingMachine = context.machineInspection.value;
|
|
627
805
|
const diagnostics = [...context.diagnostics];
|
|
628
806
|
const runtimeVersion = options.runtimeVersion ??
|
|
629
|
-
|
|
807
|
+
(options.reconfigure === true
|
|
808
|
+
? undefined
|
|
809
|
+
: existingConfig?.runtimeVersion) ??
|
|
630
810
|
getPackageInfo().version;
|
|
631
811
|
let config;
|
|
632
812
|
let machine;
|
|
633
813
|
let needWorkspaceConfig = false;
|
|
634
814
|
let needMachineConfig = false;
|
|
635
815
|
let selected = [];
|
|
636
|
-
if (existingConfig !== null) {
|
|
816
|
+
if (existingConfig !== null && options.reconfigure !== true) {
|
|
637
817
|
config = existingConfig;
|
|
638
818
|
if (choices !== null) {
|
|
639
819
|
diagnostics.push(info(diagnosticCodes.initExistingConfigReused, 'Existing valid workspace configuration is reused; init selections do not overwrite it.', context.workspaceInspection.path, 'Use a future configure flow to change existing workspace policy.'));
|
|
@@ -655,7 +835,10 @@ export async function planWorkspaceInit(options) {
|
|
|
655
835
|
if (choices === null) {
|
|
656
836
|
return failure('needs_input', diagnosticCodes.initInputRequired, 'Workspace configuration is missing; init selections are required.', diagnostics, 'Provide --config with pappra/init-choices/1 selections, or run the interactive wizard.');
|
|
657
837
|
}
|
|
658
|
-
const
|
|
838
|
+
const effectiveChoices = existingConfig !== null && choices.workspaceId === undefined
|
|
839
|
+
? { ...choices, workspaceId: existingConfig.workspaceId }
|
|
840
|
+
: choices;
|
|
841
|
+
const built = buildWorkspaceConfig(context.root, effectiveChoices, context.discovery, runtimeVersion);
|
|
659
842
|
diagnostics.push(...built.diagnostics);
|
|
660
843
|
if (built.config === null) {
|
|
661
844
|
return failureFromDiagnostics('invalid', diagnostics, diagnosticCodes.initInvalidInput, 'Init selections do not form a valid workspace configuration.');
|
|
@@ -685,7 +868,10 @@ export async function planWorkspaceInit(options) {
|
|
|
685
868
|
machine = parsedMachine.data;
|
|
686
869
|
}
|
|
687
870
|
}
|
|
688
|
-
const filePlan = planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig
|
|
871
|
+
const filePlan = planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig, {
|
|
872
|
+
workspaceDigest: context.workspaceInspection.digest,
|
|
873
|
+
machineDigest: context.machineInspection.digest,
|
|
874
|
+
});
|
|
689
875
|
const configurationDigest = filePlan.workspaceBytes !== null
|
|
690
876
|
? sha256Digest(filePlan.workspaceBytes)
|
|
691
877
|
: (context.workspaceInspection.digest ??
|
|
@@ -734,7 +920,9 @@ export async function planWorkspaceInit(options) {
|
|
|
734
920
|
workspaceId: config.workspaceId,
|
|
735
921
|
name: config.name,
|
|
736
922
|
runtimeVersion: config.runtimeVersion,
|
|
737
|
-
configSource: existingConfig !== null
|
|
923
|
+
configSource: existingConfig !== null && options.reconfigure !== true
|
|
924
|
+
? 'existing'
|
|
925
|
+
: 'planned',
|
|
738
926
|
repositories: scopeRepos,
|
|
739
927
|
knowledge: config.knowledge,
|
|
740
928
|
code: config.code,
|
|
@@ -863,13 +1051,23 @@ async function verifyBundleWrites(bundle) {
|
|
|
863
1051
|
hint: 'Regenerate the plan.',
|
|
864
1052
|
}));
|
|
865
1053
|
}
|
|
866
|
-
if (file.operation !== 'create') {
|
|
1054
|
+
if (file.operation !== 'create' && file.operation !== 'replace-managed') {
|
|
867
1055
|
diagnostics.push(createDiagnostic({
|
|
868
1056
|
code: diagnosticCodes.initApplyUnsupported,
|
|
869
1057
|
severity: 'error',
|
|
870
|
-
message: `
|
|
1058
|
+
message: `Init apply does not support operation ${file.operation} for ${file.path}.`,
|
|
871
1059
|
source: 'init plan',
|
|
872
|
-
hint: '
|
|
1060
|
+
hint: 'Regenerate the plan with create or replace-managed operations.',
|
|
1061
|
+
}));
|
|
1062
|
+
}
|
|
1063
|
+
if (file.operation === 'replace-managed' &&
|
|
1064
|
+
file.expectedBeforeDigest === null) {
|
|
1065
|
+
diagnostics.push(createDiagnostic({
|
|
1066
|
+
code: diagnosticCodes.initPlanInvalid,
|
|
1067
|
+
severity: 'error',
|
|
1068
|
+
message: `Managed replacement for ${file.path} has no expected before digest.`,
|
|
1069
|
+
source: 'init plan',
|
|
1070
|
+
hint: 'Regenerate the plan; Pappra does not replace an unverified file.',
|
|
873
1071
|
}));
|
|
874
1072
|
}
|
|
875
1073
|
if (sha256Digest(write.bytes) !== file.nextDigest) {
|
|
@@ -944,16 +1142,22 @@ export async function applyInitPlan(bundle, options = {}) {
|
|
|
944
1142
|
const workspaceInspection = await inspectWorkspaceConfig(root, {
|
|
945
1143
|
missing: 'info',
|
|
946
1144
|
});
|
|
947
|
-
const
|
|
948
|
-
|
|
949
|
-
|
|
1145
|
+
const workspaceFileIndex = bundle.plan.files.findIndex((file) => file.path === workspaceConfigRelativePath);
|
|
1146
|
+
const workspaceFile = workspaceFileIndex === -1 ? null : bundle.plan.files[workspaceFileIndex];
|
|
1147
|
+
if (workspaceFile === null &&
|
|
1148
|
+
(!workspaceInspection.exists ||
|
|
1149
|
+
workspaceInspection.value === null ||
|
|
1150
|
+
workspaceInspection.digest !== bundle.plan.configurationDigest)) {
|
|
1151
|
+
return planDiagnostics('Workspace configuration changed since planning.', diagnosticCodes.initPlanStale);
|
|
1152
|
+
}
|
|
1153
|
+
if (workspaceFile !== null) {
|
|
1154
|
+
if (workspaceFile.operation === 'create' && workspaceInspection.exists) {
|
|
950
1155
|
return applyFailure('conflict', diagnosticCodes.initTargetExists, 'Workspace configuration appeared after planning.', 'Remove or relocate the unexpected file, then retry init.');
|
|
951
1156
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
return planDiagnostics('Workspace configuration changed since planning.', diagnosticCodes.initPlanStale);
|
|
1157
|
+
if (workspaceFile.operation === 'replace-managed' &&
|
|
1158
|
+
workspaceInspection.digest !== workspaceFile.expectedBeforeDigest) {
|
|
1159
|
+
return planDiagnostics('Workspace configuration changed since planning.', diagnosticCodes.initPlanStale);
|
|
1160
|
+
}
|
|
957
1161
|
}
|
|
958
1162
|
for (const file of bundle.plan.files) {
|
|
959
1163
|
const target = await safeTarget(root, file.path);
|
|
@@ -961,29 +1165,28 @@ export async function applyInitPlan(bundle, options = {}) {
|
|
|
961
1165
|
return applyFailure('conflict', diagnosticCodes.initTargetUnsafe, `Planned target is not a safe workspace-relative path: ${file.path}`, 'Regenerate the plan; Pappra does not follow symlinked parents outside the workspace.');
|
|
962
1166
|
}
|
|
963
1167
|
const observation = await observePath(target.absolutePath);
|
|
964
|
-
if (
|
|
965
|
-
|
|
1168
|
+
if (file.operation === 'create') {
|
|
1169
|
+
if (observation.kind !== 'missing') {
|
|
1170
|
+
return applyFailure('conflict', diagnosticCodes.initTargetExists, `Planned target already exists: ${file.path}`, 'Remove or relocate the unexpected file, then retry init.');
|
|
1171
|
+
}
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
if (observation.kind !== 'file' || observation.stats === null) {
|
|
1175
|
+
return applyFailure('conflict', diagnosticCodes.initTargetExists, `Planned replacement target is not a regular file: ${file.path}`, 'Inspect the target; Pappra does not replace directories or special files.');
|
|
1176
|
+
}
|
|
1177
|
+
const before = await readFile(target.absolutePath);
|
|
1178
|
+
if (file.expectedBeforeDigest === null ||
|
|
1179
|
+
sha256Digest(before) !== file.expectedBeforeDigest) {
|
|
1180
|
+
return planDiagnostics(`Planned replacement target changed since planning: ${file.path}`, diagnosticCodes.initPlanStale);
|
|
966
1181
|
}
|
|
967
1182
|
}
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
return {
|
|
976
|
-
ok: false,
|
|
977
|
-
status,
|
|
978
|
-
appliedFiles: [],
|
|
979
|
-
pendingExternalActions: [],
|
|
980
|
-
residualPaths: managedStart.residualPaths,
|
|
981
|
-
diagnostics: managedStart.diagnostics,
|
|
982
|
-
};
|
|
983
|
-
}
|
|
984
|
-
const managed = managedStart.context;
|
|
985
|
-
let created = [];
|
|
986
|
-
let createdDirectories = [];
|
|
1183
|
+
const createIndexes = bundle.plan.files.flatMap((file, index) => file.operation === 'create' ? [index] : []);
|
|
1184
|
+
const replaceIndexes = bundle.plan.files.flatMap((file, index) => file.operation === 'replace-managed' ? [index] : []);
|
|
1185
|
+
let managed = null;
|
|
1186
|
+
const created = [];
|
|
1187
|
+
const createdDirectories = [];
|
|
1188
|
+
const replacements = [];
|
|
1189
|
+
let originalManifest = null;
|
|
987
1190
|
const ensureDirectory = async (directory) => {
|
|
988
1191
|
const missing = [];
|
|
989
1192
|
let current = directory;
|
|
@@ -1008,6 +1211,19 @@ export async function applyInitPlan(bundle, options = {}) {
|
|
|
1008
1211
|
createdDirectories.push(path);
|
|
1009
1212
|
}
|
|
1010
1213
|
};
|
|
1214
|
+
const restoreReplacements = async () => {
|
|
1215
|
+
for (const replacement of [...replacements].reverse()) {
|
|
1216
|
+
try {
|
|
1217
|
+
await writeFile(replacement.absolutePath, replacement.beforeBytes);
|
|
1218
|
+
}
|
|
1219
|
+
catch {
|
|
1220
|
+
// The residual list below and the failure diagnostic report this case.
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
if (originalManifest !== null) {
|
|
1224
|
+
await writeWorkspaceManifest(root, originalManifest).catch(() => undefined);
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1011
1227
|
const rootBeforeWrite = await observePath(root);
|
|
1012
1228
|
if (rootBeforeWrite.kind !== 'directory' ||
|
|
1013
1229
|
rootBeforeWrite.stats === null ||
|
|
@@ -1016,73 +1232,169 @@ export async function applyInitPlan(bundle, options = {}) {
|
|
|
1016
1232
|
return applyFailure('stale', diagnosticCodes.initPlanStale, 'Workspace root identity changed before writing.', 'Retry init from the current workspace root; no files were written.');
|
|
1017
1233
|
}
|
|
1018
1234
|
try {
|
|
1019
|
-
for (const
|
|
1235
|
+
for (const index of replaceIndexes) {
|
|
1236
|
+
const file = bundle.plan.files[index];
|
|
1020
1237
|
const write = bundle.writes[index];
|
|
1021
|
-
|
|
1022
|
-
|
|
1238
|
+
await options.beforeWrite?.(file.path, index);
|
|
1239
|
+
const absolutePath = resolvePortableRelativePath(root, file.path);
|
|
1240
|
+
const backupRef = backupPath(root, bundle.plan.planId, file.path);
|
|
1241
|
+
const stateBoundary = await ensureStatePathBoundary(root, backupRef);
|
|
1242
|
+
if (!stateBoundary.ok)
|
|
1243
|
+
throw new Error(stateBoundary.message);
|
|
1244
|
+
const beforeBytes = await readFile(absolutePath);
|
|
1245
|
+
if (file.expectedBeforeDigest === null ||
|
|
1246
|
+
sha256Digest(beforeBytes) !== file.expectedBeforeDigest) {
|
|
1247
|
+
throw new Error(`Replacement target changed again: ${file.path}`);
|
|
1023
1248
|
}
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
1249
|
+
const observation = await observePath(absolutePath);
|
|
1250
|
+
const mode = observation.stats === null ? 0o644 : observation.stats.mode & 0o777;
|
|
1251
|
+
await mkdir(dirname(backupRef), { recursive: true });
|
|
1252
|
+
await writeFile(backupRef, beforeBytes, { mode: 0o600, flag: 'wx' });
|
|
1253
|
+
const temporary = `${absolutePath}.pappra-replace-${randomUUID().slice(0, 8)}`;
|
|
1254
|
+
await writeFile(temporary, write.bytes, { mode });
|
|
1255
|
+
await rename(temporary, absolutePath);
|
|
1256
|
+
const after = await observePath(absolutePath);
|
|
1257
|
+
if (after.kind !== 'file' || after.stats === null) {
|
|
1258
|
+
throw new Error(`Replacement target is unreadable after writing: ${file.path}`);
|
|
1027
1259
|
}
|
|
1028
|
-
|
|
1029
|
-
await ensureDirectory(dirname(target.absolutePath));
|
|
1030
|
-
const handle = await open(target.absolutePath, 'wx');
|
|
1031
|
-
const record = {
|
|
1260
|
+
replacements.push({
|
|
1032
1261
|
path: file.path,
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1262
|
+
absolutePath,
|
|
1263
|
+
beforeBytes,
|
|
1264
|
+
backupRef,
|
|
1265
|
+
backupDigest: sha256Digest(beforeBytes),
|
|
1266
|
+
afterDigest: file.nextDigest,
|
|
1267
|
+
identity: { dev: after.stats.dev, ino: after.stats.ino },
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
if (replacements.length > 0) {
|
|
1271
|
+
const manifestRead = await readWorkspaceManifest(root);
|
|
1272
|
+
if (manifestRead.diagnostics.length > 0) {
|
|
1273
|
+
throw new Error(manifestRead.diagnostics[0]?.message ??
|
|
1274
|
+
'Workspace install manifest is unreadable.');
|
|
1043
1275
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1276
|
+
if (manifestRead.value === null) {
|
|
1277
|
+
throw new Error('Workspace install manifest is missing; Pappra does not adopt an unowned replacement.');
|
|
1046
1278
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1279
|
+
originalManifest = manifestRead.value;
|
|
1280
|
+
const entries = [...manifestRead.value.entries];
|
|
1281
|
+
const verifiedAt = new Date().toISOString();
|
|
1282
|
+
for (const replacement of replacements) {
|
|
1283
|
+
const entry = {
|
|
1284
|
+
path: replacement.path,
|
|
1285
|
+
installId: bundle.plan.planId,
|
|
1286
|
+
operation: 'replace-managed',
|
|
1287
|
+
beforeDigest: replacement.backupDigest,
|
|
1288
|
+
afterDigest: replacement.afterDigest,
|
|
1289
|
+
backupRef: replacement.backupRef,
|
|
1290
|
+
backupDigest: replacement.backupDigest,
|
|
1291
|
+
identity: replacement.identity,
|
|
1292
|
+
owner: 'pappra',
|
|
1293
|
+
verifiedAt,
|
|
1294
|
+
};
|
|
1295
|
+
const existingIndex = entries.findIndex((item) => item.path === replacement.path);
|
|
1296
|
+
if (existingIndex === -1)
|
|
1297
|
+
entries.push(entry);
|
|
1298
|
+
else
|
|
1299
|
+
entries[existingIndex] = entry;
|
|
1050
1300
|
}
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1301
|
+
const written = await writeWorkspaceManifest(root, {
|
|
1302
|
+
...manifestRead.value,
|
|
1303
|
+
entries,
|
|
1054
1304
|
});
|
|
1055
|
-
if (!
|
|
1056
|
-
throw new Error(
|
|
1057
|
-
}
|
|
1305
|
+
if (!written.ok)
|
|
1306
|
+
throw new Error(written.message);
|
|
1058
1307
|
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
return {
|
|
1064
|
-
ok: false,
|
|
1065
|
-
status: 'failed',
|
|
1066
|
-
appliedFiles: [],
|
|
1067
|
-
pendingExternalActions: [],
|
|
1068
|
-
residualPaths: failed.residualPaths,
|
|
1069
|
-
diagnostics: failed.diagnostics,
|
|
1308
|
+
if (createIndexes.length > 0) {
|
|
1309
|
+
const createPlan = {
|
|
1310
|
+
...bundle.plan,
|
|
1311
|
+
files: createIndexes.map((index) => bundle.plan.files[index]),
|
|
1070
1312
|
};
|
|
1313
|
+
const createWrites = createIndexes.map((index) => bundle.writes[index]);
|
|
1314
|
+
const managedStart = await beginManagedApply(root, createPlan, createWrites, bundle.rootIdentity);
|
|
1315
|
+
if (!managedStart.ok) {
|
|
1316
|
+
const status = managedStart.status === 'invalid'
|
|
1317
|
+
? 'stale'
|
|
1318
|
+
: managedStart.status === 'locked'
|
|
1319
|
+
? 'conflict'
|
|
1320
|
+
: 'failed';
|
|
1321
|
+
await restoreReplacements();
|
|
1322
|
+
return {
|
|
1323
|
+
ok: false,
|
|
1324
|
+
status,
|
|
1325
|
+
appliedFiles: [],
|
|
1326
|
+
pendingExternalActions: [],
|
|
1327
|
+
residualPaths: managedStart.residualPaths,
|
|
1328
|
+
diagnostics: managedStart.diagnostics,
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
managed = managedStart.context;
|
|
1332
|
+
for (const index of createIndexes) {
|
|
1333
|
+
const file = bundle.plan.files[index];
|
|
1334
|
+
const write = bundle.writes[index];
|
|
1335
|
+
const target = await safeTarget(root, file.path);
|
|
1336
|
+
if (target === null) {
|
|
1337
|
+
throw new Error(`Unsafe target: ${file.path}`);
|
|
1338
|
+
}
|
|
1339
|
+
await options.beforeWrite?.(file.path, index);
|
|
1340
|
+
await ensureDirectory(dirname(target.absolutePath));
|
|
1341
|
+
const handle = await open(target.absolutePath, 'wx');
|
|
1342
|
+
const record = {
|
|
1343
|
+
path: file.path,
|
|
1344
|
+
digest: file.nextDigest,
|
|
1345
|
+
dev: 0,
|
|
1346
|
+
ino: 0,
|
|
1347
|
+
};
|
|
1348
|
+
try {
|
|
1349
|
+
await handle.writeFile(write.bytes);
|
|
1350
|
+
await handle.sync();
|
|
1351
|
+
const stats = await handle.stat();
|
|
1352
|
+
record.dev = stats.dev;
|
|
1353
|
+
record.ino = stats.ino;
|
|
1354
|
+
}
|
|
1355
|
+
finally {
|
|
1356
|
+
await handle.close();
|
|
1357
|
+
}
|
|
1358
|
+
created.push(record);
|
|
1359
|
+
const recorded = await recordManagedFile(managed, file.path, file.nextDigest, { dev: record.dev, ino: record.ino });
|
|
1360
|
+
if (!recorded.ok) {
|
|
1361
|
+
throw new Error(recorded.diagnostics[0]?.message ?? `Cannot record ${file.path}.`);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
const completed = await completeManagedApply(managed);
|
|
1365
|
+
if (!completed.ok) {
|
|
1366
|
+
const residual = await cleanupCreatedFiles(root, created);
|
|
1367
|
+
const failed = await failManagedApply(managed, 'Cannot finalize managed installation manifest.', residual);
|
|
1368
|
+
await restoreReplacements();
|
|
1369
|
+
return {
|
|
1370
|
+
ok: false,
|
|
1371
|
+
status: 'failed',
|
|
1372
|
+
appliedFiles: [],
|
|
1373
|
+
pendingExternalActions: [],
|
|
1374
|
+
residualPaths: failed.residualPaths,
|
|
1375
|
+
diagnostics: failed.diagnostics,
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1071
1378
|
}
|
|
1379
|
+
const appliedFiles = [
|
|
1380
|
+
...created.map((file) => file.path),
|
|
1381
|
+
...replacements.map((replacement) => replacement.path),
|
|
1382
|
+
];
|
|
1072
1383
|
return {
|
|
1073
1384
|
ok: true,
|
|
1074
1385
|
status: bundle.plan.externalActions.length > 0
|
|
1075
1386
|
? 'external-actions-pending'
|
|
1076
|
-
:
|
|
1387
|
+
: appliedFiles.length === 0
|
|
1077
1388
|
? 'no-changes'
|
|
1078
1389
|
: 'applied',
|
|
1079
|
-
appliedFiles
|
|
1390
|
+
appliedFiles,
|
|
1080
1391
|
pendingExternalActions: bundle.plan.externalActions,
|
|
1081
|
-
residualPaths:
|
|
1082
|
-
diagnostics:
|
|
1392
|
+
residualPaths: [],
|
|
1393
|
+
diagnostics: [],
|
|
1083
1394
|
};
|
|
1084
1395
|
}
|
|
1085
1396
|
catch (error) {
|
|
1397
|
+
await restoreReplacements();
|
|
1086
1398
|
const residual = await cleanupCreatedFiles(root, created);
|
|
1087
1399
|
for (const directory of createdDirectories.reverse()) {
|
|
1088
1400
|
await rmdir(directory).catch(() => undefined);
|
|
@@ -1098,6 +1410,25 @@ export async function applyInitPlan(bundle, options = {}) {
|
|
|
1098
1410
|
const residualNote = residual.length === 0
|
|
1099
1411
|
? 'No planned file from this attempt remains.'
|
|
1100
1412
|
: `Residual planned files require manual inspection: ${residual.join(', ')}`;
|
|
1413
|
+
if (managed === null) {
|
|
1414
|
+
return {
|
|
1415
|
+
ok: false,
|
|
1416
|
+
status,
|
|
1417
|
+
appliedFiles: [],
|
|
1418
|
+
pendingExternalActions: [],
|
|
1419
|
+
residualPaths: residual,
|
|
1420
|
+
diagnostics: [
|
|
1421
|
+
createDiagnostic({
|
|
1422
|
+
code,
|
|
1423
|
+
severity: 'error',
|
|
1424
|
+
message: `${base}. ${residualNote}`,
|
|
1425
|
+
source: 'init apply',
|
|
1426
|
+
pointer: null,
|
|
1427
|
+
hint: 'Inspect the workspace state; backups from this attempt were restored when possible.',
|
|
1428
|
+
}),
|
|
1429
|
+
],
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1101
1432
|
const failed = await failManagedApply(managed, `${base}. ${residualNote}`, residual);
|
|
1102
1433
|
return {
|
|
1103
1434
|
ok: false,
|
|
@@ -7,5 +7,6 @@ export interface DocsCommandOptions {
|
|
|
7
7
|
readonly json: boolean;
|
|
8
8
|
readonly includePrivate: boolean;
|
|
9
9
|
readonly includeArchived: boolean;
|
|
10
|
+
readonly apply: boolean;
|
|
10
11
|
}
|
|
11
12
|
export declare function runDocsCommand(options: DocsCommandOptions, output: CliOutput): Promise<number>;
|