@phyzess/pappra 0.1.0-beta.3 → 0.1.0-beta.5
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 +9 -2
- package/dist/app/init.d.ts +2 -0
- package/dist/app/init.js +257 -87
- package/dist/cli/commands/docs.d.ts +1 -0
- package/dist/cli/commands/docs.js +104 -3
- package/dist/cli/commands/host.d.ts +2 -0
- package/dist/cli/commands/host.js +4 -1
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.js +19 -0
- package/dist/cli/run.js +37 -6
- package/dist/hosts/integration/bundle-export.d.ts +2 -0
- package/dist/hosts/integration/bundle-export.js +33 -22
- package/dist/hosts/integration/service.d.ts +6 -0
- package/dist/hosts/integration/service.js +13 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,13 @@ npx @phyzess/pappra@beta runtime status --json
|
|
|
46
46
|
npx @phyzess/pappra@beta runtime verify --json
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
A workspace that already has a valid `.pappra/workspace.json` is never rewritten silently.
|
|
50
|
+
`init --yes --reconfigure --apply` rebuilds it from discovery (for example to move the
|
|
51
|
+
recorded runtime version to the running package), backing the previous file up under
|
|
52
|
+
`.pappra/local/backups`. Apply also reports advisory `nextActions`: `pappra code prepare`
|
|
53
|
+
for a selected code engine and `pappra docs scan --apply` to register documents from the
|
|
54
|
+
configured knowledge sources.
|
|
55
|
+
|
|
49
56
|
Optional examples have prerequisites rather than generated files: code-graph and
|
|
50
57
|
host commands require the selected external CLI/support, and collaboration
|
|
51
58
|
requires an explicit controller. They are not needed for the core workflow above.
|
|
@@ -56,7 +63,7 @@ requires an explicit controller. They are not needed for the core workflow above
|
|
|
56
63
|
- **Knowledge:** workspace and private document catalogs with explicit preview/apply operations.
|
|
57
64
|
- **Continuity:** private task checkpoints and bounded advisory resume packets. Resume never grants execution by itself.
|
|
58
65
|
- **Code intelligence:** status, explicit preparation and bounded query for selected repositories. Text/structure fallback is available when the optional graph engine is absent.
|
|
59
|
-
- **Host integrations:** optional Pi, Codex and Claude Code adapters that merge Pappra-owned entries without taking over user configuration. Hooks are copied as a dependency-free bundle into the host root; `host bundle` exports a
|
|
66
|
+
- **Host integrations:** optional Pi, Codex and Claude Code adapters that merge Pappra-owned entries without taking over user configuration. Hooks are copied as a dependency-free bundle into the host root; `host bundle --marketplace` exports a local marketplace that Claude Code and Codex accept, without registering it. `host install --mode plugin` fails closed until plugin registration and callability are verified in a real session.
|
|
60
67
|
- **Bounded collaboration:** an optional, explicitly controlled one-request workflow. It is not a scheduler or daemon.
|
|
61
68
|
|
|
62
69
|
## Optional integrations and support
|
|
@@ -80,7 +87,7 @@ The check runs the source policy, type checks, formatting, deterministic tests a
|
|
|
80
87
|
|
|
81
88
|
## Status and limits
|
|
82
89
|
|
|
83
|
-
Pappra is development software (`0.1.0-beta.
|
|
90
|
+
Pappra is development software (`0.1.0-beta.5`) published as `@phyzess/pappra` under the `beta` dist-tag. Releases are published from GitHub Actions with npm trusted publishing (OIDC), so no npm token is stored in this repository. Pin either the moving `beta` tag or an exact version such as `@phyzess/pappra@0.1.0-beta.5`, because pre-release versions are never republished.
|
|
84
91
|
|
|
85
92
|
The deterministic suite covers synthetic workspaces, fixtures and local process boundaries. It does not establish real-model behavior, native host callability on every platform, CodeGraph completeness, Windows/Linux parity or production performance. A host adapter version pin is an observed support claim, not proof that a particular native installation will load or run correctly.
|
|
86
93
|
|
package/dist/app/init.d.ts
CHANGED
|
@@ -116,6 +116,8 @@ export interface PlanInitOptions {
|
|
|
116
116
|
readonly root: string;
|
|
117
117
|
readonly choices?: InitChoices | null;
|
|
118
118
|
readonly runtimeVersion?: string;
|
|
119
|
+
/** Rebuilds workspace.json from selections even when a valid config exists. */
|
|
120
|
+
readonly reconfigure?: boolean;
|
|
119
121
|
readonly planId?: string;
|
|
120
122
|
readonly discoveredAt?: string;
|
|
121
123
|
/** Reuse an immediately preceding inspectInitContext result. */
|
package/dist/app/init.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
6
|
import { INIT_CHOICES_SCHEMA, initPlanSchema, parseInitChoices, } from '../schemas/init.js';
|
|
@@ -16,6 +16,7 @@ import { inspectMachineConfig, inspectWorkspaceConfig, } from '../workspace/conf
|
|
|
16
16
|
import { discoverWorkspace, } from '../workspace/discover.js';
|
|
17
17
|
import { resolveWorkspaceRoot } from '../workspace/resolve.js';
|
|
18
18
|
import { buildInitPlan } from '../install/plan.js';
|
|
19
|
+
import { backupPath, ensureStatePathBoundary, readWorkspaceManifest, writeWorkspaceManifest, } from '../install/ownership.js';
|
|
19
20
|
import { beginManagedApply, completeManagedApply, failManagedApply, recordManagedFile, } from '../install/apply.js';
|
|
20
21
|
const workspaceConfigRelativePath = '.pappra/workspace.json';
|
|
21
22
|
const machineConfigRelativePath = '.pappra/local/machine.json';
|
|
@@ -666,22 +667,38 @@ function scopeRepositories(config, discovery) {
|
|
|
666
667
|
};
|
|
667
668
|
});
|
|
668
669
|
}
|
|
669
|
-
function planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig) {
|
|
670
|
+
function planFilesFor(config, machine, needWorkspaceConfig, needMachineConfig, existing) {
|
|
670
671
|
const workspaceBytes = needWorkspaceConfig ? jsonBytes(config) : null;
|
|
671
672
|
const machineBytes = needMachineConfig ? jsonBytes(machine) : null;
|
|
672
673
|
const files = [];
|
|
673
|
-
if (workspaceBytes !== null
|
|
674
|
+
if (workspaceBytes !== null &&
|
|
675
|
+
existing.workspaceDigest !== sha256Digest(workspaceBytes)) {
|
|
674
676
|
files.push({
|
|
675
677
|
path: workspaceConfigRelativePath,
|
|
676
678
|
bytes: workspaceBytes,
|
|
677
679
|
beforeText: null,
|
|
680
|
+
...(existing.workspaceDigest === null
|
|
681
|
+
? {}
|
|
682
|
+
: {
|
|
683
|
+
operation: 'replace-managed',
|
|
684
|
+
expectedBeforeDigest: existing.workspaceDigest,
|
|
685
|
+
backupRequired: true,
|
|
686
|
+
}),
|
|
678
687
|
});
|
|
679
688
|
}
|
|
680
|
-
if (machineBytes !== null
|
|
689
|
+
if (machineBytes !== null &&
|
|
690
|
+
existing.machineDigest !== sha256Digest(machineBytes)) {
|
|
681
691
|
files.push({
|
|
682
692
|
path: machineConfigRelativePath,
|
|
683
693
|
bytes: machineBytes,
|
|
684
694
|
beforeText: null,
|
|
695
|
+
...(existing.machineDigest === null
|
|
696
|
+
? {}
|
|
697
|
+
: {
|
|
698
|
+
operation: 'replace-managed',
|
|
699
|
+
expectedBeforeDigest: existing.machineDigest,
|
|
700
|
+
backupRequired: true,
|
|
701
|
+
}),
|
|
685
702
|
});
|
|
686
703
|
}
|
|
687
704
|
return { files, workspaceBytes, machineBytes };
|
|
@@ -787,14 +804,16 @@ export async function planWorkspaceInit(options) {
|
|
|
787
804
|
const existingMachine = context.machineInspection.value;
|
|
788
805
|
const diagnostics = [...context.diagnostics];
|
|
789
806
|
const runtimeVersion = options.runtimeVersion ??
|
|
790
|
-
|
|
807
|
+
(options.reconfigure === true
|
|
808
|
+
? undefined
|
|
809
|
+
: existingConfig?.runtimeVersion) ??
|
|
791
810
|
getPackageInfo().version;
|
|
792
811
|
let config;
|
|
793
812
|
let machine;
|
|
794
813
|
let needWorkspaceConfig = false;
|
|
795
814
|
let needMachineConfig = false;
|
|
796
815
|
let selected = [];
|
|
797
|
-
if (existingConfig !== null) {
|
|
816
|
+
if (existingConfig !== null && options.reconfigure !== true) {
|
|
798
817
|
config = existingConfig;
|
|
799
818
|
if (choices !== null) {
|
|
800
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.'));
|
|
@@ -816,7 +835,10 @@ export async function planWorkspaceInit(options) {
|
|
|
816
835
|
if (choices === null) {
|
|
817
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.');
|
|
818
837
|
}
|
|
819
|
-
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);
|
|
820
842
|
diagnostics.push(...built.diagnostics);
|
|
821
843
|
if (built.config === null) {
|
|
822
844
|
return failureFromDiagnostics('invalid', diagnostics, diagnosticCodes.initInvalidInput, 'Init selections do not form a valid workspace configuration.');
|
|
@@ -846,7 +868,10 @@ export async function planWorkspaceInit(options) {
|
|
|
846
868
|
machine = parsedMachine.data;
|
|
847
869
|
}
|
|
848
870
|
}
|
|
849
|
-
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
|
+
});
|
|
850
875
|
const configurationDigest = filePlan.workspaceBytes !== null
|
|
851
876
|
? sha256Digest(filePlan.workspaceBytes)
|
|
852
877
|
: (context.workspaceInspection.digest ??
|
|
@@ -895,7 +920,9 @@ export async function planWorkspaceInit(options) {
|
|
|
895
920
|
workspaceId: config.workspaceId,
|
|
896
921
|
name: config.name,
|
|
897
922
|
runtimeVersion: config.runtimeVersion,
|
|
898
|
-
configSource: existingConfig !== null
|
|
923
|
+
configSource: existingConfig !== null && options.reconfigure !== true
|
|
924
|
+
? 'existing'
|
|
925
|
+
: 'planned',
|
|
899
926
|
repositories: scopeRepos,
|
|
900
927
|
knowledge: config.knowledge,
|
|
901
928
|
code: config.code,
|
|
@@ -1024,13 +1051,23 @@ async function verifyBundleWrites(bundle) {
|
|
|
1024
1051
|
hint: 'Regenerate the plan.',
|
|
1025
1052
|
}));
|
|
1026
1053
|
}
|
|
1027
|
-
if (file.operation !== 'create') {
|
|
1054
|
+
if (file.operation !== 'create' && file.operation !== 'replace-managed') {
|
|
1028
1055
|
diagnostics.push(createDiagnostic({
|
|
1029
1056
|
code: diagnosticCodes.initApplyUnsupported,
|
|
1030
1057
|
severity: 'error',
|
|
1031
|
-
message: `
|
|
1058
|
+
message: `Init apply does not support operation ${file.operation} for ${file.path}.`,
|
|
1032
1059
|
source: 'init plan',
|
|
1033
|
-
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.',
|
|
1034
1071
|
}));
|
|
1035
1072
|
}
|
|
1036
1073
|
if (sha256Digest(write.bytes) !== file.nextDigest) {
|
|
@@ -1105,16 +1142,22 @@ export async function applyInitPlan(bundle, options = {}) {
|
|
|
1105
1142
|
const workspaceInspection = await inspectWorkspaceConfig(root, {
|
|
1106
1143
|
missing: 'info',
|
|
1107
1144
|
});
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1110
|
-
|
|
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) {
|
|
1111
1155
|
return applyFailure('conflict', diagnosticCodes.initTargetExists, 'Workspace configuration appeared after planning.', 'Remove or relocate the unexpected file, then retry init.');
|
|
1112
1156
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
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
|
+
}
|
|
1118
1161
|
}
|
|
1119
1162
|
for (const file of bundle.plan.files) {
|
|
1120
1163
|
const target = await safeTarget(root, file.path);
|
|
@@ -1122,29 +1165,28 @@ export async function applyInitPlan(bundle, options = {}) {
|
|
|
1122
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.');
|
|
1123
1166
|
}
|
|
1124
1167
|
const observation = await observePath(target.absolutePath);
|
|
1125
|
-
if (
|
|
1126
|
-
|
|
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);
|
|
1127
1181
|
}
|
|
1128
1182
|
}
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
return {
|
|
1137
|
-
ok: false,
|
|
1138
|
-
status,
|
|
1139
|
-
appliedFiles: [],
|
|
1140
|
-
pendingExternalActions: [],
|
|
1141
|
-
residualPaths: managedStart.residualPaths,
|
|
1142
|
-
diagnostics: managedStart.diagnostics,
|
|
1143
|
-
};
|
|
1144
|
-
}
|
|
1145
|
-
const managed = managedStart.context;
|
|
1146
|
-
let created = [];
|
|
1147
|
-
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;
|
|
1148
1190
|
const ensureDirectory = async (directory) => {
|
|
1149
1191
|
const missing = [];
|
|
1150
1192
|
let current = directory;
|
|
@@ -1169,6 +1211,19 @@ export async function applyInitPlan(bundle, options = {}) {
|
|
|
1169
1211
|
createdDirectories.push(path);
|
|
1170
1212
|
}
|
|
1171
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
|
+
};
|
|
1172
1227
|
const rootBeforeWrite = await observePath(root);
|
|
1173
1228
|
if (rootBeforeWrite.kind !== 'directory' ||
|
|
1174
1229
|
rootBeforeWrite.stats === null ||
|
|
@@ -1177,73 +1232,169 @@ export async function applyInitPlan(bundle, options = {}) {
|
|
|
1177
1232
|
return applyFailure('stale', diagnosticCodes.initPlanStale, 'Workspace root identity changed before writing.', 'Retry init from the current workspace root; no files were written.');
|
|
1178
1233
|
}
|
|
1179
1234
|
try {
|
|
1180
|
-
for (const
|
|
1235
|
+
for (const index of replaceIndexes) {
|
|
1236
|
+
const file = bundle.plan.files[index];
|
|
1181
1237
|
const write = bundle.writes[index];
|
|
1182
|
-
|
|
1183
|
-
|
|
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}`);
|
|
1184
1248
|
}
|
|
1185
|
-
const
|
|
1186
|
-
|
|
1187
|
-
|
|
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}`);
|
|
1188
1259
|
}
|
|
1189
|
-
|
|
1190
|
-
await ensureDirectory(dirname(target.absolutePath));
|
|
1191
|
-
const handle = await open(target.absolutePath, 'wx');
|
|
1192
|
-
const record = {
|
|
1260
|
+
replacements.push({
|
|
1193
1261
|
path: file.path,
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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.');
|
|
1204
1275
|
}
|
|
1205
|
-
|
|
1206
|
-
|
|
1276
|
+
if (manifestRead.value === null) {
|
|
1277
|
+
throw new Error('Workspace install manifest is missing; Pappra does not adopt an unowned replacement.');
|
|
1207
1278
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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;
|
|
1211
1300
|
}
|
|
1212
|
-
const
|
|
1213
|
-
|
|
1214
|
-
|
|
1301
|
+
const written = await writeWorkspaceManifest(root, {
|
|
1302
|
+
...manifestRead.value,
|
|
1303
|
+
entries,
|
|
1215
1304
|
});
|
|
1216
|
-
if (!
|
|
1217
|
-
throw new Error(
|
|
1218
|
-
}
|
|
1305
|
+
if (!written.ok)
|
|
1306
|
+
throw new Error(written.message);
|
|
1219
1307
|
}
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
return {
|
|
1225
|
-
ok: false,
|
|
1226
|
-
status: 'failed',
|
|
1227
|
-
appliedFiles: [],
|
|
1228
|
-
pendingExternalActions: [],
|
|
1229
|
-
residualPaths: failed.residualPaths,
|
|
1230
|
-
diagnostics: failed.diagnostics,
|
|
1308
|
+
if (createIndexes.length > 0) {
|
|
1309
|
+
const createPlan = {
|
|
1310
|
+
...bundle.plan,
|
|
1311
|
+
files: createIndexes.map((index) => bundle.plan.files[index]),
|
|
1231
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
|
+
}
|
|
1232
1378
|
}
|
|
1379
|
+
const appliedFiles = [
|
|
1380
|
+
...created.map((file) => file.path),
|
|
1381
|
+
...replacements.map((replacement) => replacement.path),
|
|
1382
|
+
];
|
|
1233
1383
|
return {
|
|
1234
1384
|
ok: true,
|
|
1235
1385
|
status: bundle.plan.externalActions.length > 0
|
|
1236
1386
|
? 'external-actions-pending'
|
|
1237
|
-
:
|
|
1387
|
+
: appliedFiles.length === 0
|
|
1238
1388
|
? 'no-changes'
|
|
1239
1389
|
: 'applied',
|
|
1240
|
-
appliedFiles
|
|
1390
|
+
appliedFiles,
|
|
1241
1391
|
pendingExternalActions: bundle.plan.externalActions,
|
|
1242
|
-
residualPaths:
|
|
1243
|
-
diagnostics:
|
|
1392
|
+
residualPaths: [],
|
|
1393
|
+
diagnostics: [],
|
|
1244
1394
|
};
|
|
1245
1395
|
}
|
|
1246
1396
|
catch (error) {
|
|
1397
|
+
await restoreReplacements();
|
|
1247
1398
|
const residual = await cleanupCreatedFiles(root, created);
|
|
1248
1399
|
for (const directory of createdDirectories.reverse()) {
|
|
1249
1400
|
await rmdir(directory).catch(() => undefined);
|
|
@@ -1259,6 +1410,25 @@ export async function applyInitPlan(bundle, options = {}) {
|
|
|
1259
1410
|
const residualNote = residual.length === 0
|
|
1260
1411
|
? 'No planned file from this attempt remains.'
|
|
1261
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
|
+
}
|
|
1262
1432
|
const failed = await failManagedApply(managed, `${base}. ${residualNote}`, residual);
|
|
1263
1433
|
return {
|
|
1264
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>;
|
|
@@ -1,11 +1,106 @@
|
|
|
1
|
+
import { discoverWorkspace } from '../../workspace/discover.js';
|
|
1
2
|
import { resolveWorkspaceRoot } from '../../workspace/resolve.js';
|
|
2
3
|
import { formatDiagnostic } from '../../schemas/diagnostics.js';
|
|
3
4
|
import { docsArchiveReportSchema, docsCheckReportSchema, docsListReportSchema, } from '../../schemas/document.js';
|
|
4
5
|
import { KnowledgeService, } from '../../knowledge/index.js';
|
|
5
|
-
export const docsUsage = 'pappra docs <list|check|archive> [options]\n' +
|
|
6
|
+
export const docsUsage = 'pappra docs <list|check|archive|scan> [options]\n' +
|
|
6
7
|
' list [--include-private] [--include-archived] [--json]\n' +
|
|
7
8
|
' check [--include-private] [--json]\n' +
|
|
8
|
-
' archive <documentId> [--json]'
|
|
9
|
+
' archive <documentId> [--json]\n' +
|
|
10
|
+
' scan [--apply] [--json]\n\n' +
|
|
11
|
+
'scan registers documents discovered under the configured knowledge sources; without\n' +
|
|
12
|
+
'--apply it only reports the candidates.';
|
|
13
|
+
function classifyScanKind(path) {
|
|
14
|
+
const lower = path.toLowerCase();
|
|
15
|
+
if (lower.endsWith('agents.md') || lower.endsWith('claude.md'))
|
|
16
|
+
return 'rule';
|
|
17
|
+
if (lower.includes('/research/'))
|
|
18
|
+
return 'research';
|
|
19
|
+
if (lower.includes('/adr/') || lower.includes('decision'))
|
|
20
|
+
return 'decision';
|
|
21
|
+
if (lower.includes('review') || lower.includes('retro'))
|
|
22
|
+
return 'review';
|
|
23
|
+
if (lower.includes('plan'))
|
|
24
|
+
return 'plan';
|
|
25
|
+
return 'architecture';
|
|
26
|
+
}
|
|
27
|
+
async function runDocsScan(options, output, root) {
|
|
28
|
+
const discovery = await discoverWorkspace(root);
|
|
29
|
+
const config = discovery.config;
|
|
30
|
+
if (config === null) {
|
|
31
|
+
return invalidArguments(output, options.json, 'docs scan requires a valid .pappra/workspace.json; run pappra init first.');
|
|
32
|
+
}
|
|
33
|
+
const service = new KnowledgeService(root);
|
|
34
|
+
const listed = await service.list({ includePrivate: true });
|
|
35
|
+
const registered = new Set(listed.records.flatMap((record) => record.location.kind === 'workspace' ? [record.location.path] : []));
|
|
36
|
+
const sources = config.knowledge.sources;
|
|
37
|
+
const visibility = config.knowledge.taskVisibility === 'shared' ? 'shareable' : 'private';
|
|
38
|
+
const candidates = (discovery.documents ?? []).flatMap((document) => {
|
|
39
|
+
const path = document.workspaceRelativePath;
|
|
40
|
+
if (path === null)
|
|
41
|
+
return [];
|
|
42
|
+
if (!sources.some((source) => path === source || path.startsWith(`${source}/`))) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
if (registered.has(path))
|
|
46
|
+
return [];
|
|
47
|
+
return [{ path, kind: classifyScanKind(path), visibility }];
|
|
48
|
+
});
|
|
49
|
+
if (!options.apply) {
|
|
50
|
+
if (options.json) {
|
|
51
|
+
output.stdout(`${JSON.stringify({
|
|
52
|
+
ok: true,
|
|
53
|
+
schema: 'pappra/docs-scan/1',
|
|
54
|
+
mode: 'plan',
|
|
55
|
+
workspaceRoot: root,
|
|
56
|
+
candidates,
|
|
57
|
+
registered: registered.size,
|
|
58
|
+
nextActions: [
|
|
59
|
+
'Rerun with --apply to register these documents in the knowledge catalog.',
|
|
60
|
+
],
|
|
61
|
+
})}\n`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
output.stdout(`Scan candidates: ${candidates.length}\n`);
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
output.stdout(` ${candidate.kind} ${candidate.path}\n`);
|
|
67
|
+
}
|
|
68
|
+
output.stdout('Plan only; rerun with --apply to register them.\n');
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
const registeredNow = [];
|
|
73
|
+
const diagnostics = [];
|
|
74
|
+
for (const candidate of candidates) {
|
|
75
|
+
const result = await service.register({
|
|
76
|
+
location: { kind: 'workspace', path: candidate.path },
|
|
77
|
+
kind: candidate.kind,
|
|
78
|
+
maturity: 'draft',
|
|
79
|
+
owner: 'human',
|
|
80
|
+
visibility: candidate.visibility,
|
|
81
|
+
});
|
|
82
|
+
diagnostics.push(...result.diagnostics);
|
|
83
|
+
if (result.ok)
|
|
84
|
+
registeredNow.push(result.record);
|
|
85
|
+
}
|
|
86
|
+
const ok = diagnostics.every((entry) => entry.severity !== 'error');
|
|
87
|
+
if (options.json) {
|
|
88
|
+
output.stdout(`${JSON.stringify({
|
|
89
|
+
ok,
|
|
90
|
+
schema: 'pappra/docs-scan/1',
|
|
91
|
+
mode: 'apply',
|
|
92
|
+
workspaceRoot: root,
|
|
93
|
+
registered: registeredNow,
|
|
94
|
+
skipped: candidates.length - registeredNow.length,
|
|
95
|
+
diagnostics,
|
|
96
|
+
})}\n`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
output.stdout(`Registered ${registeredNow.length} document(s).\n`);
|
|
100
|
+
printDiagnostics(output, diagnostics);
|
|
101
|
+
}
|
|
102
|
+
return ok ? 0 : 1;
|
|
103
|
+
}
|
|
9
104
|
function printDiagnostics(output, diagnostics) {
|
|
10
105
|
for (const diagnostic of diagnostics) {
|
|
11
106
|
output.stderr(`${formatDiagnostic(diagnostic)}\n`);
|
|
@@ -55,7 +150,7 @@ export async function runDocsCommand(options, output) {
|
|
|
55
150
|
}
|
|
56
151
|
const resolution = await resolveWorkspaceRoot({ startPath: options.cwd });
|
|
57
152
|
const serviceRoot = resolution.root ?? options.cwd;
|
|
58
|
-
if (!['list', 'check', 'archive'].includes(options.action)) {
|
|
153
|
+
if (!['list', 'check', 'archive', 'scan'].includes(options.action)) {
|
|
59
154
|
return invalidArguments(output, options.json, `unknown docs action: ${options.action}`);
|
|
60
155
|
}
|
|
61
156
|
if (options.action === 'archive' && options.argument === null) {
|
|
@@ -64,6 +159,12 @@ export async function runDocsCommand(options, output) {
|
|
|
64
159
|
if (options.action !== 'archive' && options.argument !== null) {
|
|
65
160
|
return invalidArguments(output, options.json, `docs ${options.action} does not accept an argument`);
|
|
66
161
|
}
|
|
162
|
+
if (options.action === 'scan') {
|
|
163
|
+
return runDocsScan(options, output, serviceRoot);
|
|
164
|
+
}
|
|
165
|
+
if (options.apply) {
|
|
166
|
+
return invalidArguments(output, options.json, 'only docs scan accepts --apply');
|
|
167
|
+
}
|
|
67
168
|
if (options.action === 'archive' && options.argument !== null) {
|
|
68
169
|
const service = new KnowledgeService(serviceRoot);
|
|
69
170
|
const result = await service.archive(options.argument);
|
|
@@ -10,5 +10,7 @@ export interface HostCommandOptions {
|
|
|
10
10
|
readonly hostVersion: string | null;
|
|
11
11
|
readonly apply: boolean;
|
|
12
12
|
readonly out: string | null;
|
|
13
|
+
readonly mode: 'project-settings' | 'plugin' | null;
|
|
14
|
+
readonly marketplace: boolean;
|
|
13
15
|
}
|
|
14
16
|
export declare function runHostCommand(options: HostCommandOptions, output: CliOutput): Promise<number>;
|
|
@@ -5,7 +5,8 @@ import { exportHostPluginBundle } from '../../hosts/integration/bundle-export.js
|
|
|
5
5
|
import { HOST_SUPPORT, isSupportedVersion, } from '../../hosts/integration/support.js';
|
|
6
6
|
import { getPackageInfo } from '../../package-info.js';
|
|
7
7
|
export const hostUsage = 'pappra host <detect|describe|plan|install|verify|bundle> --host <pi|codex|claude>\n' +
|
|
8
|
-
' [--host-root <dir>] [--host-version <version>] [--out <dir>]
|
|
8
|
+
' [--host-root <dir>] [--host-version <version>] [--out <dir>]\n' +
|
|
9
|
+
' [--mode project-settings|plugin] [--apply] [--json]\n' +
|
|
9
10
|
'\n' +
|
|
10
11
|
'bundle exports a host-native plugin/package directory and never registers it or\n' +
|
|
11
12
|
'claims host callability.';
|
|
@@ -102,6 +103,7 @@ export async function runHostCommand(options, output) {
|
|
|
102
103
|
const result = await service.install(options.host, {
|
|
103
104
|
...target,
|
|
104
105
|
apply: options.apply,
|
|
106
|
+
...(options.mode === null ? {} : { mode: options.mode }),
|
|
105
107
|
});
|
|
106
108
|
if (options.json) {
|
|
107
109
|
output.stdout(`${JSON.stringify(result)}\n`);
|
|
@@ -131,6 +133,7 @@ export async function runHostCommand(options, output) {
|
|
|
131
133
|
hostVersion: options.hostVersion ?? HOST_SUPPORT[options.host].version,
|
|
132
134
|
outDir: options.out,
|
|
133
135
|
packageVersion: getPackageInfo().version,
|
|
136
|
+
marketplace: options.marketplace,
|
|
134
137
|
});
|
|
135
138
|
if (options.json) {
|
|
136
139
|
output.stdout(`${JSON.stringify({ ok: true, command: 'host', action: 'bundle', ...exported })}\n`);
|
|
@@ -38,6 +38,8 @@ export interface InitCommandOptions {
|
|
|
38
38
|
readonly printChoices?: boolean;
|
|
39
39
|
/** Leaves the runtime action pending instead of installing a durable copy. */
|
|
40
40
|
readonly noRuntime?: boolean;
|
|
41
|
+
/** Rebuilds workspace.json from the selections even when a valid config exists. */
|
|
42
|
+
readonly reconfigure?: boolean;
|
|
41
43
|
}
|
|
42
44
|
export interface RuntimeFlowResult {
|
|
43
45
|
readonly ok: boolean;
|
|
@@ -586,12 +586,26 @@ function printApply(output, result, runtimeRequested) {
|
|
|
586
586
|
: 'Only planned config files were applied. Initialization is not complete until runtime/provider actions are authorized and executed.\n');
|
|
587
587
|
}
|
|
588
588
|
}
|
|
589
|
+
function advisoryNextActions(bundle) {
|
|
590
|
+
const actions = [];
|
|
591
|
+
const repositoryId = bundle.scope.repositories[0]?.id;
|
|
592
|
+
if (bundle.scope.code.graph !== 'none' &&
|
|
593
|
+
bundle.scope.repositories.length > 0 &&
|
|
594
|
+
repositoryId !== undefined) {
|
|
595
|
+
actions.push(`pappra code prepare --repo ${repositoryId} --apply # register the ${bundle.scope.code.graph} index for this checkout`);
|
|
596
|
+
}
|
|
597
|
+
if (bundle.scope.knowledge.sources.length > 0) {
|
|
598
|
+
actions.push('pappra docs scan --apply # register discovered knowledge documents in the catalog');
|
|
599
|
+
}
|
|
600
|
+
return actions;
|
|
601
|
+
}
|
|
589
602
|
function planJson(bundle) {
|
|
590
603
|
return {
|
|
591
604
|
command: 'init',
|
|
592
605
|
mode: 'plan',
|
|
593
606
|
plan: bundle.plan,
|
|
594
607
|
scope: bundle.scope,
|
|
608
|
+
nextActions: advisoryNextActions(bundle),
|
|
595
609
|
diagnostics: bundle.diagnostics,
|
|
596
610
|
};
|
|
597
611
|
}
|
|
@@ -716,6 +730,7 @@ export async function runInitCommand(options, output, deps = {}) {
|
|
|
716
730
|
root: context.root,
|
|
717
731
|
choices,
|
|
718
732
|
context,
|
|
733
|
+
...(options.reconfigure === true ? { reconfigure: true } : {}),
|
|
719
734
|
});
|
|
720
735
|
if (!prepared.ok)
|
|
721
736
|
return writeFailure(output, options, prepared);
|
|
@@ -748,6 +763,7 @@ export async function runInitCommand(options, output, deps = {}) {
|
|
|
748
763
|
apply: applied,
|
|
749
764
|
runtime: runtimeFlow?.runtime ?? null,
|
|
750
765
|
initComplete,
|
|
766
|
+
nextActions: advisoryNextActions(bundle),
|
|
751
767
|
diagnostics: [
|
|
752
768
|
...bundle.diagnostics,
|
|
753
769
|
...(runtimeFlow?.diagnostics ?? []),
|
|
@@ -814,6 +830,9 @@ export async function runInitCommand(options, output, deps = {}) {
|
|
|
814
830
|
return 1;
|
|
815
831
|
}
|
|
816
832
|
output.stdout('Durable runtime copy installed and bound. Initialization complete.\n');
|
|
833
|
+
for (const action of advisoryNextActions(bundle)) {
|
|
834
|
+
output.stdout(` next: ${action}\n`);
|
|
835
|
+
}
|
|
817
836
|
return 0;
|
|
818
837
|
}
|
|
819
838
|
return applied.status === 'external-actions-pending' ? 1 : 0;
|
package/dist/cli/run.js
CHANGED
|
@@ -20,9 +20,12 @@ function parseOptions(args) {
|
|
|
20
20
|
apply: { type: 'boolean' },
|
|
21
21
|
yes: { type: 'boolean', short: 'y' },
|
|
22
22
|
'no-runtime': { type: 'boolean' },
|
|
23
|
+
reconfigure: { type: 'boolean' },
|
|
23
24
|
from: { type: 'string' },
|
|
24
25
|
tarball: { type: 'string' },
|
|
25
26
|
out: { type: 'string' },
|
|
27
|
+
mode: { type: 'string' },
|
|
28
|
+
marketplace: { type: 'boolean' },
|
|
26
29
|
bind: { type: 'boolean' },
|
|
27
30
|
force: { type: 'boolean' },
|
|
28
31
|
'no-interactive': { type: 'boolean' },
|
|
@@ -182,7 +185,11 @@ function parseOptions(args) {
|
|
|
182
185
|
};
|
|
183
186
|
}
|
|
184
187
|
function failure(output, json, code, error) {
|
|
185
|
-
const message =
|
|
188
|
+
const message = typeof error === 'string'
|
|
189
|
+
? error
|
|
190
|
+
: error instanceof Error
|
|
191
|
+
? error.message
|
|
192
|
+
: 'Operation failed.';
|
|
186
193
|
if (json) {
|
|
187
194
|
output.stdout(`${JSON.stringify({ ok: false, code, message })}\n`);
|
|
188
195
|
}
|
|
@@ -230,18 +237,36 @@ export async function runCli(args, output) {
|
|
|
230
237
|
}
|
|
231
238
|
const { values, command, docsAction, docsArgument, codeAction, hostAction, collaborationAction, resumeTaskId, runtimeAction, } = parsed;
|
|
232
239
|
const json = values.json === true;
|
|
240
|
+
const applyRequested = values.apply === true;
|
|
233
241
|
if (command !== 'init' &&
|
|
234
242
|
(values.yes === true ||
|
|
235
243
|
values['no-interactive'] === true ||
|
|
236
244
|
values['print-choices'] === true ||
|
|
237
|
-
values['no-runtime'] === true
|
|
238
|
-
|
|
245
|
+
values['no-runtime'] === true ||
|
|
246
|
+
values.reconfigure === true)) {
|
|
247
|
+
failure(output, json, 'invalid_arguments', '--yes, --no-interactive, --print-choices, --no-runtime and --reconfigure are only valid for init.');
|
|
239
248
|
return 2;
|
|
240
249
|
}
|
|
241
250
|
if (command !== 'host' && values.out !== undefined) {
|
|
242
251
|
failure(output, json, 'invalid_arguments', '--out is only valid for host.');
|
|
243
252
|
return 2;
|
|
244
253
|
}
|
|
254
|
+
if (command !== 'host' && values.marketplace === true) {
|
|
255
|
+
failure(output, json, 'invalid_arguments', '--marketplace is only valid for host bundle.');
|
|
256
|
+
return 2;
|
|
257
|
+
}
|
|
258
|
+
if (command !== 'host' &&
|
|
259
|
+
values.mode !== undefined &&
|
|
260
|
+
command !== 'runtime') {
|
|
261
|
+
failure(output, json, 'invalid_arguments', '--mode is only valid for host.');
|
|
262
|
+
return 2;
|
|
263
|
+
}
|
|
264
|
+
if (values.mode !== undefined &&
|
|
265
|
+
values.mode !== 'project-settings' &&
|
|
266
|
+
values.mode !== 'plugin') {
|
|
267
|
+
failure(output, json, 'invalid_arguments', '--mode accepts project-settings or plugin.');
|
|
268
|
+
return 2;
|
|
269
|
+
}
|
|
245
270
|
if (command !== 'runtime' &&
|
|
246
271
|
(values.from !== undefined ||
|
|
247
272
|
values.tarball !== undefined ||
|
|
@@ -284,7 +309,6 @@ export async function runCli(args, output) {
|
|
|
284
309
|
if (action === null ||
|
|
285
310
|
values.version ||
|
|
286
311
|
values.config !== undefined ||
|
|
287
|
-
values.apply === true ||
|
|
288
312
|
values['install-runtime'] === true ||
|
|
289
313
|
values['runtime-tarball'] !== undefined ||
|
|
290
314
|
values['runtime-version'] !== undefined ||
|
|
@@ -309,13 +333,12 @@ export async function runCli(args, output) {
|
|
|
309
333
|
if (command === 'docs') {
|
|
310
334
|
if (values.version ||
|
|
311
335
|
values.config !== undefined ||
|
|
312
|
-
values.apply === true ||
|
|
313
336
|
values['install-runtime'] === true ||
|
|
314
337
|
values['runtime-tarball'] !== undefined ||
|
|
315
338
|
values['runtime-version'] !== undefined ||
|
|
316
339
|
values.root !== undefined ||
|
|
317
340
|
hasContinuityOptions(values)) {
|
|
318
|
-
failure(output, json, 'invalid_arguments', 'docs only accepts list/check/archive, --include-private, --include-archived, --help, and --json.');
|
|
341
|
+
failure(output, json, 'invalid_arguments', 'docs only accepts list/check/archive/scan, --include-private, --include-archived, --apply (scan only), --help, and --json.');
|
|
319
342
|
return 2;
|
|
320
343
|
}
|
|
321
344
|
if (values.help) {
|
|
@@ -334,6 +357,7 @@ export async function runCli(args, output) {
|
|
|
334
357
|
json,
|
|
335
358
|
includePrivate: values['include-private'] === true,
|
|
336
359
|
includeArchived: values['include-archived'] === true,
|
|
360
|
+
apply: applyRequested,
|
|
337
361
|
}, output);
|
|
338
362
|
}
|
|
339
363
|
if (command === 'doctor') {
|
|
@@ -395,6 +419,7 @@ export async function runCli(args, output) {
|
|
|
395
419
|
yes: values.yes === true || values['no-interactive'] === true,
|
|
396
420
|
printChoices: values['print-choices'] === true,
|
|
397
421
|
noRuntime: values['no-runtime'] === true,
|
|
422
|
+
reconfigure: values.reconfigure === true,
|
|
398
423
|
}, output);
|
|
399
424
|
}
|
|
400
425
|
if (command === 'runtime') {
|
|
@@ -638,6 +663,8 @@ export async function runCli(args, output) {
|
|
|
638
663
|
values.rebuild === true ||
|
|
639
664
|
hasContinuityOptions(values) ||
|
|
640
665
|
(values.apply === true && action !== 'install') ||
|
|
666
|
+
(values.mode !== undefined && action !== 'install') ||
|
|
667
|
+
(values.marketplace === true && action !== 'bundle') ||
|
|
641
668
|
(values['host-root'] !== undefined && action === null) ||
|
|
642
669
|
(values['host-version'] !== undefined && action === null);
|
|
643
670
|
if (values.help) {
|
|
@@ -662,6 +689,10 @@ export async function runCli(args, output) {
|
|
|
662
689
|
hostVersion: values['host-version'] ?? null,
|
|
663
690
|
apply: values.apply === true,
|
|
664
691
|
out: values.out ?? null,
|
|
692
|
+
mode: values.mode === 'project-settings' || values.mode === 'plugin'
|
|
693
|
+
? values.mode
|
|
694
|
+
: null,
|
|
695
|
+
marketplace: values.marketplace === true,
|
|
665
696
|
}, output);
|
|
666
697
|
}
|
|
667
698
|
if (command !== null) {
|
|
@@ -19,4 +19,6 @@ export declare function exportHostPluginBundle(options: {
|
|
|
19
19
|
readonly hostVersion: string;
|
|
20
20
|
readonly outDir: string;
|
|
21
21
|
readonly packageVersion: string;
|
|
22
|
+
/** Writes a marketplace root with the plugin under `pappra/`. */
|
|
23
|
+
readonly marketplace?: boolean;
|
|
22
24
|
}): Promise<HostPluginBundle>;
|
|
@@ -10,24 +10,15 @@ function pluginHookExpression(host) {
|
|
|
10
10
|
: './hooks/pappra-hook.mjs';
|
|
11
11
|
}
|
|
12
12
|
function hostManifest(host, version) {
|
|
13
|
-
if (host === 'claude') {
|
|
13
|
+
if (host === 'claude' || host === 'codex') {
|
|
14
|
+
// Codex reads the Claude plugin manifest path, so one manifest serves both.
|
|
14
15
|
return {
|
|
15
16
|
path: '.claude-plugin/plugin.json',
|
|
16
17
|
value: {
|
|
17
18
|
name: 'pappra',
|
|
18
19
|
version,
|
|
19
20
|
description: 'Portable workspace knowledge and continuity for coding agents.',
|
|
20
|
-
|
|
21
|
-
},
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
if (host === 'codex') {
|
|
25
|
-
return {
|
|
26
|
-
path: 'plugin.json',
|
|
27
|
-
value: {
|
|
28
|
-
name: 'pappra',
|
|
29
|
-
version,
|
|
30
|
-
description: 'Portable workspace knowledge and continuity for coding agents.',
|
|
21
|
+
author: { name: 'phyzess' },
|
|
31
22
|
hooks: './hooks/hooks.json',
|
|
32
23
|
},
|
|
33
24
|
};
|
|
@@ -53,15 +44,20 @@ function hostManifest(host, version) {
|
|
|
53
44
|
* load or trust it.
|
|
54
45
|
*/
|
|
55
46
|
export async function exportHostPluginBundle(options) {
|
|
56
|
-
const
|
|
47
|
+
const outRoot = resolve(options.outDir);
|
|
48
|
+
if (options.marketplace === true && options.host === 'pi') {
|
|
49
|
+
throw new Error('Pi installs a directory directly; a marketplace wrapper is not supported.');
|
|
50
|
+
}
|
|
51
|
+
const pluginPrefix = options.marketplace === true ? 'pappra/' : '';
|
|
52
|
+
const pluginRoot = options.marketplace === true ? join(outRoot, 'pappra') : outRoot;
|
|
57
53
|
const payload = await loadHostBundle(options.host, options.packageVersion, {
|
|
58
54
|
hookSource: join(getPackageRoot(), 'dist/hosts/integration/pappra-hook.mjs'),
|
|
59
55
|
hookExpression: pluginHookExpression(options.host),
|
|
60
56
|
});
|
|
61
57
|
const written = [];
|
|
62
58
|
const writeEntry = async (path, bytes) => {
|
|
63
|
-
const target = join(
|
|
64
|
-
if (!isPathInside(
|
|
59
|
+
const target = join(outRoot, ...path.split('/'));
|
|
60
|
+
if (!isPathInside(outRoot, target)) {
|
|
65
61
|
throw new Error(`Unsafe plugin bundle path: ${path}`);
|
|
66
62
|
}
|
|
67
63
|
await mkdir(dirname(target), { recursive: true });
|
|
@@ -73,7 +69,7 @@ export async function exportHostPluginBundle(options) {
|
|
|
73
69
|
// templates are only an intermediate artifact.
|
|
74
70
|
if (options.host !== 'pi' && path.startsWith('templates/'))
|
|
75
71
|
continue;
|
|
76
|
-
await writeEntry(path
|
|
72
|
+
await writeEntry(`${pluginPrefix}${path}`, bytes);
|
|
77
73
|
}
|
|
78
74
|
const expression = pluginHookExpression(options.host);
|
|
79
75
|
if (options.host === 'pi') {
|
|
@@ -81,20 +77,35 @@ export async function exportHostPluginBundle(options) {
|
|
|
81
77
|
if (entry === undefined) {
|
|
82
78
|
throw new Error('Pi plugin bundle is missing its extension entry.');
|
|
83
79
|
}
|
|
84
|
-
await writeEntry(
|
|
80
|
+
await writeEntry(`${pluginPrefix}extensions/pappra/index.ts`, entry);
|
|
85
81
|
}
|
|
86
82
|
else {
|
|
87
83
|
const patch = renderHookConfigPatch(options.host, expression);
|
|
88
|
-
await writeEntry(
|
|
84
|
+
await writeEntry(`${pluginPrefix}hooks/hooks.json`, Buffer.from(`${JSON.stringify(patch, null, 2)}\n`));
|
|
89
85
|
}
|
|
90
86
|
const manifest = hostManifest(options.host, options.packageVersion);
|
|
91
|
-
await writeEntry(manifest.path
|
|
87
|
+
await writeEntry(`${pluginPrefix}${manifest.path}`, Buffer.from(`${JSON.stringify(manifest.value, null, 2)}\n`));
|
|
88
|
+
if (options.marketplace === true) {
|
|
89
|
+
await writeEntry('.claude-plugin/marketplace.json', Buffer.from(`${JSON.stringify({
|
|
90
|
+
name: 'pappra-local',
|
|
91
|
+
owner: { name: 'phyzess' },
|
|
92
|
+
description: 'Local Pappra plugin marketplace exported by pappra host bundle.',
|
|
93
|
+
plugins: [
|
|
94
|
+
{
|
|
95
|
+
name: 'pappra',
|
|
96
|
+
source: './pappra',
|
|
97
|
+
description: 'Portable workspace knowledge and continuity for coding agents.',
|
|
98
|
+
author: { name: 'phyzess' },
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
}, null, 2)}\n`));
|
|
102
|
+
}
|
|
92
103
|
return {
|
|
93
104
|
host: options.host,
|
|
94
|
-
outDir,
|
|
105
|
+
outDir: pluginRoot,
|
|
95
106
|
files: written.sort((left, right) => left.localeCompare(right)),
|
|
96
|
-
pluginManifest: manifest.path
|
|
97
|
-
hookConfig: options.host === 'pi' ? null :
|
|
107
|
+
pluginManifest: `${pluginPrefix}${manifest.path}`,
|
|
108
|
+
hookConfig: options.host === 'pi' ? null : `${pluginPrefix}hooks/hooks.json`,
|
|
98
109
|
registration: options.host === 'claude'
|
|
99
110
|
? [
|
|
100
111
|
'Register the directory as a local plugin or marketplace entry; Pappra does not add it to host settings.',
|
|
@@ -14,6 +14,12 @@ export interface HostTargetOptions {
|
|
|
14
14
|
readonly hostVersion?: string | null;
|
|
15
15
|
/** Rewrites a manifest previously pinned to a package checkout path. */
|
|
16
16
|
readonly migrate?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Plugin wiring is exported with `host bundle` but not installed here: host
|
|
19
|
+
* registration, trust and callability are unverified, so this mode fails
|
|
20
|
+
* closed instead of writing a bundle a host may never load.
|
|
21
|
+
*/
|
|
22
|
+
readonly mode?: 'project-settings' | 'plugin';
|
|
17
23
|
}
|
|
18
24
|
export interface HostPlanBundle {
|
|
19
25
|
readonly plan: HostIntegrationPlan;
|
|
@@ -884,6 +884,19 @@ export class HostIntegrationService {
|
|
|
884
884
|
return planFromFiles(host, this.#root, hostRoot, target.expression, built.bundle.bundleDigest, built.files);
|
|
885
885
|
}
|
|
886
886
|
async install(host, options) {
|
|
887
|
+
if (options.mode === 'plugin') {
|
|
888
|
+
return hostIntegrationResultSchema.parse({
|
|
889
|
+
schema: 'pappra/host-integration/1',
|
|
890
|
+
ok: false,
|
|
891
|
+
status: 'unsupported',
|
|
892
|
+
plan: null,
|
|
893
|
+
appliedFiles: [],
|
|
894
|
+
residualPaths: [],
|
|
895
|
+
diagnostics: [
|
|
896
|
+
diagnostic(diagnosticCodes.host_unavailable, 'Plugin install mode is not wired: host registration, trust and callability are unverified.', this.#root, 'Use `pappra host bundle --host <name> --out <dir>` to export the plugin directory and register it yourself; project-settings install remains the verified mode.'),
|
|
897
|
+
],
|
|
898
|
+
});
|
|
899
|
+
}
|
|
887
900
|
let plan;
|
|
888
901
|
let contents;
|
|
889
902
|
try {
|