@aefree/pi-plastic 0.5.5 → 0.5.7
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/CHANGELOG.md +12 -0
- package/README.md +4 -4
- package/index.ts +24 -1
- package/package.json +3 -4
- package/skills/using-plastic/SKILL.md +1 -1
- package/src/plastic-core.ts +208 -5
- package/SECURITY.md +0 -31
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project follows semantic versioning for public package releases.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## [0.5.7] - 2026-09-14
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Bind automatic pending-file diff bases to the owning workspace or Xlink repository/server before materialization, preventing colliding revision IDs from another repository from producing plausible but unrelated diffs. Unresolved ownership is reported as unavailable.
|
|
15
|
+
|
|
16
|
+
## [0.5.6] - 2026-08-27
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Warn once per interactive Pi runtime when a configured `PI_PLASTIC_PATCH_EXECUTABLE` cannot launch or Windows lacks its required patch backend setting. Warnings redact configured paths; `plastic_patch` retains its existing tool-time validation.
|
|
21
|
+
|
|
10
22
|
## [0.5.5] - 2026-08-14
|
|
11
23
|
|
|
12
24
|
### Changed
|
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ Pi 0.82 and newer use canonical `sourceInfo` provenance to identify this package
|
|
|
73
73
|
The Bash guards run only in Pi processes that actually load this package. A project-local install does not protect a delegated child process whose working directory resolves different project settings. Install `pi-plastic` at user scope (the default `pi install`, without `-l`) when subagents and sessions in arbitrary workspaces must inherit the guards. Restart existing Pi processes after installation or source changes.
|
|
74
74
|
|
|
75
75
|
- `plastic_branchCreate` supports an explicit parent branch independent of the loaded workspace branch, defaults relative names to the current branch when no parent is supplied, and rejects top-level paths unless `allowRootBranch=true` is explicit.
|
|
76
|
-
- `plastic_diff` remains a disabled alias by design. Use `plastic_status` for changed-path listing; do not diff as routine post-edit validation or checkin preflight. When change-boundary evidence is needed, use `plastic_diffFile` for one exact file, `plastic_workspaceDiff` with selected paths or explicit `allPending=true`, or `plastic_diffRevisions` for an explicit historical pair. Diff responses use small defaults and caller-controlled `maxChars` bounds. Historical and workspace bytes are copied to ASCII-safe package-owned paths before the backend runs, while normalized headers retain the logical Unicode labels.
|
|
76
|
+
- `plastic_diff` remains a disabled alias by design. Use `plastic_status` for changed-path listing; do not diff as routine post-edit validation or checkin preflight. When change-boundary evidence is needed, use `plastic_diffFile` for one exact file, `plastic_workspaceDiff` with selected paths or explicit `allPending=true`, or `plastic_diffRevisions` for an explicit historical pair. Diff responses use small defaults and caller-controlled `maxChars` bounds. Automatic pending-file bases bind each status revision ID to its owning workspace or Xlink repository/server, including mixed-Xlink reviews. If ownership cannot be resolved, that comparison is reported unavailable rather than retrieving a bare revision from the caller repository. Historical and workspace bytes are copied to ASCII-safe package-owned paths before the backend runs, while normalized headers retain the logical Unicode labels.
|
|
77
77
|
- `plastic_status(machineReadable=true, format="json")` returns parsed `items`, aggregate `summary`, and `itemCount` (`total`, `returned`, `omitted`) rather than requiring agents to parse Plastic rows. It returns at most 100 items by default; set `maxItems` (up to 500) when more are intentional. `includeRaw=true` adds the original unbounded Plastic output for diagnostics, so omit it for normal agent use to conserve context tokens.
|
|
78
78
|
- Preflight is not routine confirmation. Use it for ambiguous or broad mutation scope, moved/deleted path rewriting, compound operations, or explicit preview requests; otherwise rely on exact targets and the tools' runtime guards.
|
|
79
79
|
- `plastic_patch` generates review patches with `cm patch`, including `clean` and `integration` filters for branch review workflows. It does not expose patch apply. Unqualified `br:/...` selectors are qualified only with the current workspace selector's exact repository; otherwise pass `br:/<branch>@<repository>@<server>`.
|
|
@@ -108,13 +108,13 @@ pi install npm:@aefree/pi-plastic
|
|
|
108
108
|
Install a pinned GitHub release over HTTPS:
|
|
109
109
|
|
|
110
110
|
```bash
|
|
111
|
-
pi install git:github.com/aefreedman/pi-plastic@v0.5.
|
|
111
|
+
pi install git:github.com/aefreedman/pi-plastic@v0.5.7
|
|
112
112
|
```
|
|
113
113
|
|
|
114
114
|
Equivalent SSH install:
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
|
-
pi install git:git@github.com/aefreedman/pi-plastic@v0.5.
|
|
117
|
+
pi install git:git@github.com/aefreedman/pi-plastic@v0.5.7
|
|
118
118
|
```
|
|
119
119
|
|
|
120
120
|
To intentionally track the moving default branch instead of a release tag:
|
|
@@ -143,7 +143,7 @@ Project-local installation protects only Pi processes that load those project se
|
|
|
143
143
|
- Pi 0.82.0 or newer; the current development and eval baseline is Pi 0.83
|
|
144
144
|
- Plastic SCM / Unity Version Control CLI (`cm`) available on `PATH`, or `PI_PLASTIC_CM_EXECUTABLE` set to its full executable path
|
|
145
145
|
- A GNU/POSIX-compatible text `diff` available on `PATH`, or `PI_PLASTIC_DIFF_EXECUTABLE` set to its full executable path (including paths containing spaces), for text-only diff tools. Pi invokes that resolved executable as-is and does not discover Git Bash paths automatically. On macOS, bare `diff` is PATH-dependent: it may be GNU Diffutils (often installed as `diff` ahead of system paths) or Apple’s BSD `/usr/bin/diff`; the package does not substitute one for the other. Configure the intended text executable explicitly when that distinction matters.
|
|
146
|
-
- Patch generation has a separate executable policy: `toolPath` is the one-call highest-priority override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On Windows, set one to a verified patch-capable non-GUI executable such as Git's `diff.exe`; the package deliberately does not reuse `PI_PLASTIC_DIFF_EXECUTABLE`/GnuWin32 as a patch default. macOS validation with Plastic 11 found that Apple BSD `/usr/bin/diff` supports text-only diffs but rejects the `--binary` argument supplied by `cm patch`; configure a verified GNU Diffutils-compatible executable explicitly for `plastic_patch`. The non-Windows fallback to `PI_PLASTIC_DIFF_EXECUTABLE` or bare `diff` remains only for environments whose resolved executable satisfies that patch contract.
|
|
146
|
+
- Patch generation has a separate executable policy: `toolPath` is the one-call highest-priority override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On Windows, set one to a verified patch-capable non-GUI executable such as Git's `diff.exe`; the package deliberately does not reuse `PI_PLASTIC_DIFF_EXECUTABLE`/GnuWin32 as a patch default. macOS validation with Plastic 11 found that Apple BSD `/usr/bin/diff` supports text-only diffs but rejects the `--binary` argument supplied by `cm patch`; configure a verified GNU Diffutils-compatible executable explicitly for `plastic_patch`. The non-Windows fallback to `PI_PLASTIC_DIFF_EXECUTABLE` or bare `diff` remains only for environments whose resolved executable satisfies that patch contract. In an interactive Pi session, an invalid configured patch override or missing Windows patch setting produces one package capability warning per Pi runtime; the warning omits configured paths, and `plastic_patch` retains its tool-time validation.
|
|
147
147
|
- A configured Plastic workspace for workspace-scoped operations. Text diffs require an ASCII-safe temporary directory; if the configured backend cannot accept Unicode paths, set `TEMP` and `TMP` to a writable ASCII-only location.
|
|
148
148
|
- `pi-file-discovery` is an optional independently loaded integration. When its `discover_candidate_files` tool is active, it receives the advisory Plastic ignore/cloak filter through the shared global capability protocol; when absent, `pi-plastic` loads without file-discovery filtering. The tarball does not embed linked sibling workspaces or `node_modules` paths.
|
|
149
149
|
|
package/index.ts
CHANGED
|
@@ -656,6 +656,28 @@ function metadataDescription(node: CoreSchemaNode): string {
|
|
|
656
656
|
return node.metadata?.description ?? "Allowed values.";
|
|
657
657
|
}
|
|
658
658
|
|
|
659
|
+
type CapabilityDiagnosticContext = {
|
|
660
|
+
hasUI?: boolean;
|
|
661
|
+
ui?: { notify?: (message: string, level: "warning") => void };
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
type PatchCapabilityWarningGetter = () => Promise<string | undefined>;
|
|
665
|
+
|
|
666
|
+
export const __plasticCapabilityDiagnosticInternals = {
|
|
667
|
+
createPatchCapabilityWarningNotifier(getWarning: PatchCapabilityWarningGetter = () => core.getPatchBackendCapabilityWarning()) {
|
|
668
|
+
let emitted = false;
|
|
669
|
+
return async (ctx: CapabilityDiagnosticContext): Promise<void> => {
|
|
670
|
+
if (emitted || !ctx.hasUI || typeof ctx.ui?.notify !== "function") return;
|
|
671
|
+
const warning = await getWarning();
|
|
672
|
+
if (!warning) return;
|
|
673
|
+
emitted = true;
|
|
674
|
+
ctx.ui.notify(warning, "warning");
|
|
675
|
+
};
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const notifyPatchCapabilityWarning = __plasticCapabilityDiagnosticInternals.createPatchCapabilityWarningNotifier();
|
|
680
|
+
|
|
659
681
|
function buildParameters(args: Record<string, unknown> | undefined): TSchema {
|
|
660
682
|
if (!args || Object.keys(args).length === 0) {
|
|
661
683
|
return EMPTY_PARAMETERS;
|
|
@@ -770,7 +792,8 @@ export default function plasticTools(pi: ExtensionAPI) {
|
|
|
770
792
|
},
|
|
771
793
|
});
|
|
772
794
|
|
|
773
|
-
pi.on("session_start", (_event, ctx) => {
|
|
795
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
796
|
+
await notifyPatchCapabilityWarning(ctx as CapabilityDiagnosticContext);
|
|
774
797
|
const ownership = getEffectivePlasticToolOwnership(pi.getAllTools(), EXTENSION_SOURCE_PATH);
|
|
775
798
|
const active = pi.getActiveTools();
|
|
776
799
|
const mode = getPlasticToolLoadingMode();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aefree/pi-plastic",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.19.0"
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
"skills/",
|
|
19
19
|
"CHANGELOG.md",
|
|
20
20
|
"LICENSE",
|
|
21
|
-
"README.md"
|
|
22
|
-
"SECURITY.md"
|
|
21
|
+
"README.md"
|
|
23
22
|
],
|
|
24
23
|
"pi": {
|
|
25
24
|
"extensions": [
|
|
@@ -34,7 +33,7 @@
|
|
|
34
33
|
]
|
|
35
34
|
},
|
|
36
35
|
"scripts": {
|
|
37
|
-
"test": "npx --yes tsx tests/plastic-tool-validation.ts && npx --yes tsx tests/plastic-status-machine-readable.test.ts && npx --yes tsx tests/plastic-checkin-path-resolution.ts && npx --yes tsx tests/plastic-diff-workflow.test.ts && npx --yes tsx tests/plastic-workspace-diff.test.ts && npx --yes tsx tests/plastic-process-lifecycle.test.ts && npx --yes tsx tests/plastic-patch-command.test.ts && npx --yes tsx tests/plastic-extension-registration.test.ts && npx --yes tsx tests/plastic-dynamic-tool-loading.test.ts && npx --yes tsx tests/plastic-tool-rendering.test.ts && npx --yes tsx tests/plastic-branch-query.test.ts && npx --yes tsx tests/plastic-workspace.test.ts && npx --yes tsx tests/plastic-mutation-execution.test.ts && npx --yes tsx tests/file-discovery-filter.test.ts && npx --yes tsx tests/file-discovery-filter-registration.test.ts && npx --yes tsx tests/packed-copy-owner-conflict.test.ts && npx --yes tsx tests/plastic-branch-status.test.ts && npx --yes tsx tests/plastic-constrained-sampling.test.ts && npx --yes tsx tests/bash-cm-diff-guard-validation.ts",
|
|
36
|
+
"test": "npx --yes tsx tests/plastic-tool-validation.ts && npx --yes tsx tests/plastic-status-machine-readable.test.ts && npx --yes tsx tests/plastic-checkin-path-resolution.ts && npx --yes tsx tests/plastic-diff-workflow.test.ts && npx --yes tsx tests/plastic-workspace-diff.test.ts && npx --yes tsx tests/plastic-process-lifecycle.test.ts && npx --yes tsx tests/plastic-patch-command.test.ts && npx --yes tsx tests/plastic-capability-diagnostics.test.ts && npx --yes tsx tests/plastic-extension-registration.test.ts && npx --yes tsx tests/plastic-dynamic-tool-loading.test.ts && npx --yes tsx tests/plastic-tool-rendering.test.ts && npx --yes tsx tests/plastic-branch-query.test.ts && npx --yes tsx tests/plastic-workspace.test.ts && npx --yes tsx tests/plastic-mutation-execution.test.ts && npx --yes tsx tests/file-discovery-filter.test.ts && npx --yes tsx tests/file-discovery-filter-registration.test.ts && npx --yes tsx tests/packed-copy-owner-conflict.test.ts && npx --yes tsx tests/plastic-branch-status.test.ts && npx --yes tsx tests/plastic-constrained-sampling.test.ts && npx --yes tsx tests/bash-cm-diff-guard-validation.ts",
|
|
38
37
|
"test:live": "npx --yes tsx tests/plastic-live-smoke.test.ts",
|
|
39
38
|
"test:live:branch-create": "npx --yes tsx tests/plastic-branch-create-live.test.ts",
|
|
40
39
|
"eval:tool-loading": "npx --yes tsx evals/tool-loading/run-eval.ts"
|
|
@@ -22,7 +22,7 @@ Plastic merges are workspace- and branch-oriented, not Git fast-forwards: the me
|
|
|
22
22
|
|
|
23
23
|
`plastic_workspaceDiff` requires selected paths or explicit `allPending=true`, runs status once, has small default file/per-file/combined-output bounds, and returns per-file outcomes without aborting when one file is unavailable. Selected private paths are included; whole-workspace private review additionally requires `includePrivate=true`. Keep `maxChars` small unless more output is intentional. Text-diff selection and patch-backend selection are distinct: text tools use `PI_PLASTIC_DIFF_EXECUTABLE` or bare PATH `diff`. On macOS, bare `diff` can resolve either to GNU Diffutils or Apple’s BSD `/usr/bin/diff`; Pi uses the resolved executable and does not auto-select `gdiff`.
|
|
24
24
|
|
|
25
|
-
Use `plastic_patch` for review patch generation with `clean` and `integration` filters; it is generation-only and does not apply patches. `toolPath` is its highest-priority one-call patch-backend override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On Windows, configure one to a verified patch-capable non-GUI executable such as Git's `diff.exe`: patch generation deliberately does not reuse `PI_PLASTIC_DIFF_EXECUTABLE`/GnuWin32. On macOS with Plastic 11, Apple BSD `/usr/bin/diff` works for text-only `plastic_diffFile` but rejects the `--binary` argument used by `cm patch`; set the patch-specific policy to a verified GNU Diffutils-compatible non-GUI executable. The non-Windows fallback to the text setting or bare `diff` is only valid when that executable accepts the local Plastic patch arguments. Unqualified `br:/...` patch selectors are qualified only from the current workspace's exact selector repository; otherwise pass `br:/<branch>@<repository>@<server>`. For a large patch, pass an intentional new `output` path: the package stages and atomically publishes it, never overwriting an existing path. Do not use `cm cat --raw`: it is unsupported for this workflow. Typed retrieval owns cleanup; the low-level byte-preserving fallback is `cm cat --file` only when a typed tool cannot represent the case. Plastic/server output may represent moves as moves or delete/add records, so inspect generated patches rather than assuming a move-aware encoding.
|
|
25
|
+
Use `plastic_patch` for review patch generation with `clean` and `integration` filters; it is generation-only and does not apply patches. `toolPath` is its highest-priority one-call patch-backend override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On Windows, configure one to a verified patch-capable non-GUI executable such as Git's `diff.exe`: patch generation deliberately does not reuse `PI_PLASTIC_DIFF_EXECUTABLE`/GnuWin32. On macOS with Plastic 11, Apple BSD `/usr/bin/diff` works for text-only `plastic_diffFile` but rejects the `--binary` argument used by `cm patch`; set the patch-specific policy to a verified GNU Diffutils-compatible non-GUI executable. The non-Windows fallback to the text setting or bare `diff` is only valid when that executable accepts the local Plastic patch arguments. Interactive Pi sessions issue one path-redacted package capability warning per runtime for an invalid configured patch override or missing Windows setting; fix `PI_PLASTIC_PATCH_EXECUTABLE` and retry, while headless sessions retain `plastic_patch` tool-time validation. Unqualified `br:/...` patch selectors are qualified only from the current workspace's exact selector repository; otherwise pass `br:/<branch>@<repository>@<server>`. For a large patch, pass an intentional new `output` path: the package stages and atomically publishes it, never overwriting an existing path. Do not use `cm cat --raw`: it is unsupported for this workflow. Typed retrieval owns cleanup; the low-level byte-preserving fallback is `cm cat --file` only when a typed tool cannot represent the case. Plastic/server output may represent moves as moves or delete/add records, so inspect generated patches rather than assuming a move-aware encoding.
|
|
26
26
|
|
|
27
27
|
Prefer runtime `plastic_*` tools first. Keep `cm` shell commands as manual fallback.
|
|
28
28
|
|
package/src/plastic-core.ts
CHANGED
|
@@ -32,6 +32,7 @@ const getCmExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC
|
|
|
32
32
|
// Keep the executable name configurable rather than assuming a .exe suffix or package-manager path.
|
|
33
33
|
const getDiffExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC_DIFF_EXECUTABLE", "diff");
|
|
34
34
|
const PLASTIC_PATCH_EXECUTABLE_ENV = "PI_PLASTIC_PATCH_EXECUTABLE";
|
|
35
|
+
type PatchExecutableProbe = (command: string) => Promise<boolean>;
|
|
35
36
|
// Plastic's server/backend determines whether a moved item is encoded as a
|
|
36
37
|
// move or as delete/add records. Do not claim either without a live fixture.
|
|
37
38
|
const PATCH_MOVE_REPRESENTATION = "backend-determined" as const;
|
|
@@ -312,6 +313,7 @@ type PendingItem = {
|
|
|
312
313
|
kind: PendingItemKind;
|
|
313
314
|
revisionId?: string;
|
|
314
315
|
sourceWorkspacePath?: string;
|
|
316
|
+
baseRepository?: string;
|
|
315
317
|
};
|
|
316
318
|
|
|
317
319
|
type PendingItemSummary = {
|
|
@@ -1228,6 +1230,49 @@ const assertNonBlankPatchValue = (name: keyof PatchCommandArgs, value: string |
|
|
|
1228
1230
|
}
|
|
1229
1231
|
};
|
|
1230
1232
|
|
|
1233
|
+
const probePatchExecutable = async (command: string): Promise<boolean> =>
|
|
1234
|
+
{
|
|
1235
|
+
try
|
|
1236
|
+
{
|
|
1237
|
+
// This establishes only that the configured executable can launch.
|
|
1238
|
+
// `cm patch` remains the authoritative verifier for its --binary contract.
|
|
1239
|
+
const result = await spawnAndCollect(command, ["--version"], process.cwd(), undefined, AbortSignal.timeout(3000), {
|
|
1240
|
+
abortKillDelayMs: 100,
|
|
1241
|
+
outputLimitChars: 1024,
|
|
1242
|
+
});
|
|
1243
|
+
return !result.aborted;
|
|
1244
|
+
}
|
|
1245
|
+
catch
|
|
1246
|
+
{
|
|
1247
|
+
return false;
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
export const getPatchBackendCapabilityWarning = async (
|
|
1252
|
+
environment: ExecutableEnvironment = process.env,
|
|
1253
|
+
platform: NodeJS.Platform = process.platform,
|
|
1254
|
+
probe: PatchExecutableProbe = probePatchExecutable,
|
|
1255
|
+
): Promise<string | undefined> =>
|
|
1256
|
+
{
|
|
1257
|
+
const configuredPatchExecutable = environment[PLASTIC_PATCH_EXECUTABLE_ENV]?.trim();
|
|
1258
|
+
if (configuredPatchExecutable)
|
|
1259
|
+
{
|
|
1260
|
+
if (await probe(configuredPatchExecutable))
|
|
1261
|
+
{
|
|
1262
|
+
return undefined;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
return "Pi Plastic capability warning: the configured PI_PLASTIC_PATCH_EXECUTABLE could not be launched. Set it to a verified patch-capable non-GUI diff executable, or remove the override to use this platform's documented fallback. The configured path is not shown for privacy.";
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
if (platform === "win32")
|
|
1269
|
+
{
|
|
1270
|
+
return "Pi Plastic capability warning: plastic_patch requires PI_PLASTIC_PATCH_EXECUTABLE on Windows. Set it to a verified patch-capable non-GUI diff executable (for example Git's diff.exe). PI_PLASTIC_DIFF_EXECUTABLE is only for text diffs.";
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
return undefined;
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1231
1276
|
const resolvePatchToolPath = (
|
|
1232
1277
|
toolPath?: string,
|
|
1233
1278
|
environment: ExecutableEnvironment = process.env,
|
|
@@ -1491,6 +1536,7 @@ export const __plasticProcessInternals = {
|
|
|
1491
1536
|
export const __plasticPatchInternals = {
|
|
1492
1537
|
buildPatchCommandArgs,
|
|
1493
1538
|
resolvePatchToolPath,
|
|
1539
|
+
getPatchBackendCapabilityWarning,
|
|
1494
1540
|
qualifyPatchBranchSpec,
|
|
1495
1541
|
resolvePatchBranchSpecs,
|
|
1496
1542
|
createPatchOutputStaging,
|
|
@@ -1698,6 +1744,153 @@ const materializeRevision = async (revision: string, destination: string, workdi
|
|
|
1698
1744
|
}
|
|
1699
1745
|
};
|
|
1700
1746
|
|
|
1747
|
+
type PendingBaseIdentityResolver = {
|
|
1748
|
+
resolve: (item: PendingItem) => Promise<string>;
|
|
1749
|
+
};
|
|
1750
|
+
|
|
1751
|
+
type RepositoryIdentity = {
|
|
1752
|
+
repository: string;
|
|
1753
|
+
server: string;
|
|
1754
|
+
};
|
|
1755
|
+
|
|
1756
|
+
const parseRepositoryIdentity = (selector: string | undefined): RepositoryIdentity | null =>
|
|
1757
|
+
{
|
|
1758
|
+
if (!selector || /[\u0000-\u001f\u007f]/.test(selector))
|
|
1759
|
+
{
|
|
1760
|
+
return null;
|
|
1761
|
+
}
|
|
1762
|
+
// Repository names cannot contain @; cloud server names can (for example,
|
|
1763
|
+
// an organization-qualified cloud endpoint), so split only at the first @.
|
|
1764
|
+
const separator = selector.indexOf("@");
|
|
1765
|
+
if (separator <= 0 || separator === selector.length - 1)
|
|
1766
|
+
{
|
|
1767
|
+
return null;
|
|
1768
|
+
}
|
|
1769
|
+
return { repository: selector.slice(0, separator), server: selector.slice(separator + 1) };
|
|
1770
|
+
};
|
|
1771
|
+
|
|
1772
|
+
const parseRepositorySelector = (output: string): RepositoryIdentity | null =>
|
|
1773
|
+
{
|
|
1774
|
+
const match = output.match(/^\s*repository\s+(?:"([^"]+)"|(\S+))\s*$/mi);
|
|
1775
|
+
return parseRepositoryIdentity(match?.[1] ?? match?.[2]);
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
const parseXlinkRepositorySelector = (output: string): RepositoryIdentity | null =>
|
|
1779
|
+
{
|
|
1780
|
+
// `cm xlink --show` reports wxlink:<serverpath>@<loaded-revision>@<repository>@<server>.
|
|
1781
|
+
const match = output.match(/^.+?\s+-->\s+wxlink:.+?@(?:\d+|cs:\d+)@(.+)\s*$/mi);
|
|
1782
|
+
return parseRepositoryIdentity(match?.[1]?.trim());
|
|
1783
|
+
};
|
|
1784
|
+
|
|
1785
|
+
const isNotXlinkError = (error: unknown): boolean => /\bis not an xlink\.?\s*$/i.test(error instanceof Error ? error.message.trim() : String(error).trim());
|
|
1786
|
+
|
|
1787
|
+
const createPendingBaseIdentityResolver = (cwd: string, workdir?: string): PendingBaseIdentityResolver =>
|
|
1788
|
+
{
|
|
1789
|
+
let workspaceRoot: Promise<string> | undefined;
|
|
1790
|
+
const getWorkspaceRoot = (): Promise<string> =>
|
|
1791
|
+
{
|
|
1792
|
+
workspaceRoot ??= discoverPlasticWorkspace(cwd).then((outcome) =>
|
|
1793
|
+
{
|
|
1794
|
+
if (outcome.kind !== "found")
|
|
1795
|
+
{
|
|
1796
|
+
throw new Error(`Plastic cannot determine the workspace root for pending base resolution: ${outcome.kind === "unavailable" ? outcome.reason : "no workspace marker found"}.`);
|
|
1797
|
+
}
|
|
1798
|
+
return toNormalizedAbsolutePath(outcome.value.root, cwd);
|
|
1799
|
+
});
|
|
1800
|
+
return workspaceRoot;
|
|
1801
|
+
};
|
|
1802
|
+
const xlinkLookups = new Map<string, Promise<RepositoryIdentity | null>>();
|
|
1803
|
+
let workspaceRepository: Promise<RepositoryIdentity> | undefined;
|
|
1804
|
+
const getWorkspaceRepository = (): Promise<RepositoryIdentity> =>
|
|
1805
|
+
{
|
|
1806
|
+
workspaceRepository ??= runCmRaw(["showselector"], workdir).then((output) =>
|
|
1807
|
+
{
|
|
1808
|
+
const repository = parseRepositorySelector(output);
|
|
1809
|
+
if (!repository)
|
|
1810
|
+
{
|
|
1811
|
+
throw new Error("Plastic returned no repository/server identity for this workspace.");
|
|
1812
|
+
}
|
|
1813
|
+
return repository;
|
|
1814
|
+
});
|
|
1815
|
+
return workspaceRepository;
|
|
1816
|
+
};
|
|
1817
|
+
const getXlinkRepository = (candidate: string): Promise<RepositoryIdentity | null> =>
|
|
1818
|
+
{
|
|
1819
|
+
const key = toPathComparisonKeyFromAbsolutePath(candidate);
|
|
1820
|
+
let lookup = xlinkLookups.get(key);
|
|
1821
|
+
if (!lookup)
|
|
1822
|
+
{
|
|
1823
|
+
lookup = runCmRaw(["xlink", "--show", candidate], workdir)
|
|
1824
|
+
.then((output) =>
|
|
1825
|
+
{
|
|
1826
|
+
const repository = parseXlinkRepositorySelector(output);
|
|
1827
|
+
if (!repository)
|
|
1828
|
+
{
|
|
1829
|
+
throw new Error(`Plastic returned malformed Xlink ownership for '${candidate}'.`);
|
|
1830
|
+
}
|
|
1831
|
+
return repository;
|
|
1832
|
+
})
|
|
1833
|
+
.catch((error) =>
|
|
1834
|
+
{
|
|
1835
|
+
if (isNotXlinkError(error))
|
|
1836
|
+
{
|
|
1837
|
+
return null;
|
|
1838
|
+
}
|
|
1839
|
+
throw error;
|
|
1840
|
+
});
|
|
1841
|
+
xlinkLookups.set(key, lookup);
|
|
1842
|
+
}
|
|
1843
|
+
return lookup;
|
|
1844
|
+
};
|
|
1845
|
+
|
|
1846
|
+
return {
|
|
1847
|
+
async resolve(item)
|
|
1848
|
+
{
|
|
1849
|
+
if (!item.revisionId)
|
|
1850
|
+
{
|
|
1851
|
+
throw new Error(`Plastic status did not provide a base revision ID for '${item.workspacePath}'.`);
|
|
1852
|
+
}
|
|
1853
|
+
const root = await getWorkspaceRoot();
|
|
1854
|
+
const ownershipPath = item.kind === "moved" && item.sourceWorkspacePath
|
|
1855
|
+
? toNormalizedAbsolutePath(item.sourceWorkspacePath, cwd)
|
|
1856
|
+
: item.normalizedPath;
|
|
1857
|
+
if (!isWithinPathScope(toPathComparisonKeyFromAbsolutePath(ownershipPath), toPathComparisonKeyFromAbsolutePath(root)))
|
|
1858
|
+
{
|
|
1859
|
+
throw new Error(`Plastic cannot resolve an owning repository for '${item.workspacePath}' outside the requested workspace.`);
|
|
1860
|
+
}
|
|
1861
|
+
let candidate = dirname(ownershipPath);
|
|
1862
|
+
while (isWithinPathScope(toPathComparisonKeyFromAbsolutePath(candidate), toPathComparisonKeyFromAbsolutePath(root)))
|
|
1863
|
+
{
|
|
1864
|
+
// A missing ancestor can be a removed Xlink mount. Status does not
|
|
1865
|
+
// retain ownership, so parent-repository fallback would be unsafe.
|
|
1866
|
+
if ((item.kind === "deleted" || item.kind === "moved") && !(await fs.stat(candidate).catch(() => null)))
|
|
1867
|
+
{
|
|
1868
|
+
throw new Error(`Plastic cannot resolve the owning repository for '${item.workspacePath}' because an ownership ancestor is missing.`);
|
|
1869
|
+
}
|
|
1870
|
+
const repository = await getXlinkRepository(candidate);
|
|
1871
|
+
if (repository)
|
|
1872
|
+
{
|
|
1873
|
+
item.baseRepository = `${repository.repository}@${repository.server}`;
|
|
1874
|
+
return `revid:${item.revisionId}@rep:${repository.repository}@repserver:${repository.server}`;
|
|
1875
|
+
}
|
|
1876
|
+
if (candidate === root)
|
|
1877
|
+
{
|
|
1878
|
+
break;
|
|
1879
|
+
}
|
|
1880
|
+
const parent = dirname(candidate);
|
|
1881
|
+
if (parent === candidate)
|
|
1882
|
+
{
|
|
1883
|
+
break;
|
|
1884
|
+
}
|
|
1885
|
+
candidate = parent;
|
|
1886
|
+
}
|
|
1887
|
+
const repository = await getWorkspaceRepository();
|
|
1888
|
+
item.baseRepository = `${repository.repository}@${repository.server}`;
|
|
1889
|
+
return `revid:${item.revisionId}@rep:${repository.repository}@repserver:${repository.server}`;
|
|
1890
|
+
},
|
|
1891
|
+
};
|
|
1892
|
+
};
|
|
1893
|
+
|
|
1701
1894
|
const normalizeDiffResponseMaxChars = (value: unknown): number =>
|
|
1702
1895
|
{
|
|
1703
1896
|
if (typeof value !== "number" || !Number.isFinite(value))
|
|
@@ -3127,6 +3320,7 @@ type WorkspacePendingDiff = {
|
|
|
3127
3320
|
const diffPendingWorkspaceFile = async (
|
|
3128
3321
|
args: { path: string; workdir?: string },
|
|
3129
3322
|
pendingItem?: PendingItem,
|
|
3323
|
+
baseIdentityResolver?: PendingBaseIdentityResolver,
|
|
3130
3324
|
): Promise<WorkspacePendingDiff> =>
|
|
3131
3325
|
{
|
|
3132
3326
|
const cwd = args.workdir ?? process.cwd();
|
|
@@ -3150,11 +3344,19 @@ const diffPendingWorkspaceFile = async (
|
|
|
3150
3344
|
? `${revisionPath} (empty before private/new file)`
|
|
3151
3345
|
: workspaceBase.resolved.includes("#") ? workspaceBase.resolved.replace("#", "@") : `${revisionPath} (Plastic base)`;
|
|
3152
3346
|
const rightLabel = isDeleted ? `${displayPath} (empty after delete)` : `${displayPath} (workspace)`;
|
|
3153
|
-
const materializeSpec = pendingItem?.revisionId ? `revid:${pendingItem.revisionId}` : workspaceBase.resolved;
|
|
3154
3347
|
if (isDeleted && !pendingItem?.revisionId)
|
|
3155
3348
|
{
|
|
3156
3349
|
throw new Error(`Plastic status identified '${args.path}' as deleted but did not provide its base revision ID.`);
|
|
3157
3350
|
}
|
|
3351
|
+
let materializeSpec = workspaceBase.resolved;
|
|
3352
|
+
if (pendingItem?.revisionId && !isAdded && !isPrivate)
|
|
3353
|
+
{
|
|
3354
|
+
if (!baseIdentityResolver)
|
|
3355
|
+
{
|
|
3356
|
+
throw new Error(`Plastic cannot resolve the owning repository for '${args.path}'.`);
|
|
3357
|
+
}
|
|
3358
|
+
materializeSpec = await baseIdentityResolver.resolve(pendingItem);
|
|
3359
|
+
}
|
|
3158
3360
|
|
|
3159
3361
|
const tempDir = await createAsciiTempDirectory("plastic-core-");
|
|
3160
3362
|
const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
|
|
@@ -3259,14 +3461,14 @@ export const diffFile = tool({
|
|
|
3259
3461
|
const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
|
|
3260
3462
|
const pendingItems = await getMachineReadablePendingItems(args.workdir);
|
|
3261
3463
|
const pendingItem = pendingItems.find((item) => item.comparisonKey === toPathComparisonKeyFromAbsolutePath(workspacePath));
|
|
3262
|
-
const compared = await diffPendingWorkspaceFile(args, pendingItem);
|
|
3464
|
+
const compared = await diffPendingWorkspaceFile(args, pendingItem, createPendingBaseIdentityResolver(cwd, args.workdir));
|
|
3263
3465
|
return formatTextDiff(
|
|
3264
3466
|
"diffFile",
|
|
3265
3467
|
args.format,
|
|
3266
3468
|
compared.comparisonKind,
|
|
3267
3469
|
compared.result,
|
|
3268
3470
|
args.workdir,
|
|
3269
|
-
{ pendingKind: compared.pendingKind, isNew: compared.pendingKind === "private" || compared.pendingKind === "added", statusRevisionId: pendingItem?.revisionId },
|
|
3471
|
+
{ pendingKind: compared.pendingKind, isNew: compared.pendingKind === "private" || compared.pendingKind === "added", statusRevisionId: pendingItem?.revisionId, baseRepositoryResolved: Boolean(pendingItem?.baseRepository) },
|
|
3270
3472
|
args.maxChars,
|
|
3271
3473
|
);
|
|
3272
3474
|
},
|
|
@@ -3445,6 +3647,7 @@ export const workspaceDiff = tool({
|
|
|
3445
3647
|
const skippedByLimit = Math.max(0, candidates.length - maxFiles);
|
|
3446
3648
|
candidates = candidates.slice(0, maxFiles);
|
|
3447
3649
|
|
|
3650
|
+
const baseIdentityResolver = createPendingBaseIdentityResolver(cwd, args.workdir);
|
|
3448
3651
|
const outcomes: Array<Record<string, unknown>> = [];
|
|
3449
3652
|
const textSections: string[] = [];
|
|
3450
3653
|
let totalOutputChars = 0;
|
|
@@ -3468,13 +3671,13 @@ export const workspaceDiff = tool({
|
|
|
3468
3671
|
}
|
|
3469
3672
|
try
|
|
3470
3673
|
{
|
|
3471
|
-
const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item);
|
|
3674
|
+
const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item, baseIdentityResolver);
|
|
3472
3675
|
const remaining = Math.max(0, WORKSPACE_DIFF_CONTENT_MAX_CHARS - totalOutputChars);
|
|
3473
3676
|
const perFile = boundWorkspaceDiffResult(compared.result, maxChars);
|
|
3474
3677
|
const bounded = boundWorkspaceDiffResult(perFile, remaining);
|
|
3475
3678
|
const status = bounded.binary ? (bounded.changed ? "binary-different" : "unchanged") : (bounded.changed ? "changed" : (compared.comparisonKind === "workspace-added" ? "added-empty" : "unchanged"));
|
|
3476
3679
|
const diffOutput = bounded.changed && !bounded.binary ? bounded.output : undefined;
|
|
3477
|
-
outcomes.push({ path: displayPath, kind: item.kind, comparisonKind: compared.comparisonKind, status, changed: bounded.changed, binary: bounded.binary, truncated: bounded.truncated, totalChars: bounded.totalChars, ...(diffOutput ? { diff: diffOutput } : {}) });
|
|
3680
|
+
outcomes.push({ path: displayPath, kind: item.kind, comparisonKind: compared.comparisonKind, status, changed: bounded.changed, binary: bounded.binary, truncated: bounded.truncated, totalChars: bounded.totalChars, baseRepositoryResolved: Boolean(item.baseRepository), ...(diffOutput ? { diff: diffOutput } : {}) });
|
|
3478
3681
|
const body = bounded.binary ? (bounded.changed ? "Binary content differs; a text diff is unavailable." : "No differences.") : (bounded.changed ? bounded.output : (status === "added-empty" ? "Added file is empty; no text diff hunks." : "No differences."));
|
|
3479
3682
|
const section = `### ${displayPath} (${item.kind})\n${body}`;
|
|
3480
3683
|
textSections.push(section);
|
package/SECURITY.md
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# Security Policy
|
|
2
|
-
|
|
3
|
-
## Supported versions
|
|
4
|
-
|
|
5
|
-
Security fixes are provided for the latest tagged release. Upgrade to the newest release before reporting an issue that may already be fixed.
|
|
6
|
-
|
|
7
|
-
## Reporting a vulnerability
|
|
8
|
-
|
|
9
|
-
Do not open a public issue for a suspected vulnerability, credential exposure, or unsafe command-execution path.
|
|
10
|
-
|
|
11
|
-
Use GitHub's private vulnerability reporting for this repository:
|
|
12
|
-
|
|
13
|
-
<https://github.com/aefreedman/pi-plastic/security/advisories/new>
|
|
14
|
-
|
|
15
|
-
Include:
|
|
16
|
-
|
|
17
|
-
- the affected package version or commit;
|
|
18
|
-
- the Pi, Node.js, Git, and Plastic CLI versions;
|
|
19
|
-
- the operating system;
|
|
20
|
-
- reproduction steps and expected versus observed behavior;
|
|
21
|
-
- the affected tool or extension;
|
|
22
|
-
- impact and any known mitigations;
|
|
23
|
-
- whether credentials, repository content, or remote Plastic objects may have been exposed or changed.
|
|
24
|
-
|
|
25
|
-
Avoid including real credentials or sensitive repository content. Use a disposable Plastic workspace when a reproduction requires mutation.
|
|
26
|
-
|
|
27
|
-
You should receive an acknowledgement within seven days. Release timing depends on severity, reproducibility, and coordination needs.
|
|
28
|
-
|
|
29
|
-
## Scope
|
|
30
|
-
|
|
31
|
-
Security-sensitive areas include command construction, executable-path overrides, workspace/path targeting, destructive Plastic operations, provider payload capture, dynamic tool ownership, and package-install behavior. General support questions and ordinary bugs belong in the public issue tracker.
|