@papillon6814/copi 0.1.0 → 0.2.1
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/LICENSE.unicode +39 -0
- package/README.md +57 -5
- package/dist/native/rename-exclusive-arm64 +0 -0
- package/dist/native/rename-exclusive-arm64.manifest.json +12 -0
- package/dist/src/apply.d.ts +9 -0
- package/dist/src/apply.js +110 -9
- package/dist/src/catalog.d.ts +21 -4
- package/dist/src/catalog.js +265 -133
- package/dist/src/cli.d.ts +2 -1
- package/dist/src/cli.js +73 -3
- package/dist/src/groups.js +8 -3
- package/dist/src/inspect.d.ts +3 -1
- package/dist/src/inspect.js +16 -6
- package/dist/src/instruction-reference.d.ts +6 -0
- package/dist/src/instruction-reference.js +21 -0
- package/dist/src/manage.d.ts +2 -0
- package/dist/src/manage.js +329 -0
- package/dist/src/migrate.d.ts +25 -0
- package/dist/src/migrate.js +171 -0
- package/dist/src/migration-backup.d.ts +22 -0
- package/dist/src/migration-backup.js +403 -0
- package/dist/src/migration-inventory.d.ts +9 -0
- package/dist/src/migration-inventory.js +237 -0
- package/dist/src/migration-resolution.d.ts +28 -0
- package/dist/src/migration-resolution.js +1618 -0
- package/dist/src/migration.d.ts +49 -0
- package/dist/src/migration.js +1004 -0
- package/dist/src/names.d.ts +11 -0
- package/dist/src/names.js +40 -0
- package/dist/src/native-placement.d.ts +16 -0
- package/dist/src/native-placement.js +557 -0
- package/dist/src/refresh.d.ts +20 -0
- package/dist/src/refresh.js +143 -0
- package/dist/src/resource-mutations.d.ts +45 -0
- package/dist/src/resource-mutations.js +225 -0
- package/dist/src/source-content.d.ts +4 -0
- package/dist/src/source-content.js +208 -0
- package/dist/src/storage-journal.d.ts +111 -0
- package/dist/src/storage-journal.js +613 -0
- package/dist/src/storage-root-lease.d.ts +7 -0
- package/dist/src/storage-root-lease.js +92 -0
- package/dist/src/storage-transaction.d.ts +100 -0
- package/dist/src/storage-transaction.js +3951 -0
- package/dist/src/terminal-editor.d.ts +8 -0
- package/dist/src/terminal-editor.js +391 -0
- package/dist/src/types.d.ts +18 -2
- package/dist/src/unicode-case-folding-data.d.ts +1 -0
- package/dist/src/unicode-case-folding-data.js +1596 -0
- package/dist/src/unicode-case-folding.d.ts +2 -0
- package/dist/src/unicode-case-folding.js +9 -0
- package/dist/src/wizard.d.ts +12 -3
- package/dist/src/wizard.js +287 -19
- package/package.json +5 -3
package/LICENSE.unicode
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
UNICODE LICENSE V3
|
|
2
|
+
|
|
3
|
+
COPYRIGHT AND PERMISSION NOTICE
|
|
4
|
+
|
|
5
|
+
Copyright © 1991-2026 Unicode, Inc.
|
|
6
|
+
|
|
7
|
+
NOTICE TO USER: Carefully read the following legal agreement. BY
|
|
8
|
+
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
|
|
9
|
+
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
|
|
10
|
+
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
|
|
11
|
+
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a
|
|
14
|
+
copy of data files and any associated documentation (the "Data Files") or
|
|
15
|
+
software and any associated documentation (the "Software") to deal in the
|
|
16
|
+
Data Files or Software without restriction, including without limitation
|
|
17
|
+
the rights to use, copy, modify, merge, publish, distribute, and/or sell
|
|
18
|
+
copies of the Data Files or Software, and to permit persons to whom the
|
|
19
|
+
Data Files or Software are furnished to do so, provided that either (a)
|
|
20
|
+
this copyright and permission notice appear with all copies of the Data
|
|
21
|
+
Files or Software, or (b) this copyright and permission notice appear in
|
|
22
|
+
associated Documentation.
|
|
23
|
+
|
|
24
|
+
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
|
|
25
|
+
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
26
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
|
27
|
+
THIRD PARTY RIGHTS.
|
|
28
|
+
|
|
29
|
+
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
|
|
30
|
+
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
|
|
31
|
+
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
|
|
32
|
+
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
|
33
|
+
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
|
|
34
|
+
FILES OR SOFTWARE.
|
|
35
|
+
|
|
36
|
+
Except as contained in this notice, the name of a copyright holder shall
|
|
37
|
+
not be used in advertising or otherwise to promote the sale, use or other
|
|
38
|
+
dealings in these Data Files or Software without prior written
|
|
39
|
+
authorization of the copyright holder.
|
package/README.md
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
## Status and requirements
|
|
6
6
|
|
|
7
7
|
- Package: `@papillon6814/copi`; command: `copi`.
|
|
8
|
-
- macOS only; Node.js 22.19.0 or newer.
|
|
9
|
-
-
|
|
10
|
-
-
|
|
8
|
+
- macOS only (saving resources into the library requires Apple Silicon / arm64); Node.js 22.19.0 or newer.
|
|
9
|
+
- Install with `npm install --global @papillon6814/copi`. npm creates the `copi` command.
|
|
10
|
+
- Version 0.1.0 predates the catalog v2 `sources` layout. Use 0.2.0 or newer with a migrated library.
|
|
11
11
|
- An older, unrelated tool may already provide a `copi` command. Check `command -v copi` before installing globally; resolve any command-name conflict deliberately.
|
|
12
12
|
|
|
13
13
|
For a local checkout, run `npm install --ignore-scripts`, `npm run build`, then `node dist/src/cli.js help`.
|
|
@@ -16,22 +16,74 @@ For a local checkout, run `npm install --ignore-scripts`, `npm run build`, then
|
|
|
16
16
|
|
|
17
17
|
```text
|
|
18
18
|
copi setup
|
|
19
|
+
copi manage
|
|
19
20
|
copi groups
|
|
20
21
|
copi list
|
|
21
22
|
copi apply <group> [target]
|
|
22
23
|
copi apply <group> [target] --dry-run
|
|
24
|
+
copi migrate [--dry-run]
|
|
25
|
+
copi migrate --resolve [--dry-run]
|
|
23
26
|
copi help
|
|
24
27
|
```
|
|
25
28
|
|
|
26
29
|
`setup` requires an interactive terminal. It asks which locations may be searched and asks for explicit authorization before searching. Selecting the entire `HOME` is broad: it can inspect supported resources throughout that directory, including Pi profiles and the configured Pi agent directory. Selecting the current project scopes discovery to that project. The wizard then lets you choose individual resources, review masked previews, assign each saved resource to one or more groups, and confirm before anything is copied into the library. Declining authorization performs no search.
|
|
27
30
|
|
|
31
|
+
`manage` requires both stdin and stdout to be an interactive TTY. Choose saved resources and groups by name; UUID input is not required and normal screens identify resources by display name, kind, groups and destination. It provides masked viewing, terminal text editing, display-name changes, separate destination changes, source refresh, group management and apply. Running without arguments, or with `help`, `--help` or `-h`, still shows help.
|
|
32
|
+
|
|
33
|
+
Resource display names and group names are each unique across the library, in separate namespaces. Comparison uses `trim → NFKC → Unicode 17.0.0 default full case folding → NFKC` (not locale-specific); display names and body text are not automatically normalized or rewritten. For example, `A` and `a`, or `Straße` and `STRASSE`, conflict within one namespace. Legacy duplicate catalogs remain readable: use **Resolve name conflicts** to rename them manually. Until resolved, only listing, masked viewing and conflict resolution are allowed; no automatic renaming occurs.
|
|
34
|
+
|
|
28
35
|
`groups` lists groups in a non-interactive terminal; group changes require a TTY and are saved only after confirmation. `list` is read-only. `apply --dry-run` prints the planned paths, masked text diffs, and warnings without changing the target. A real apply requires a TTY and confirmation.
|
|
29
36
|
|
|
30
|
-
|
|
37
|
+
Pi automatically loads only context files (`AGENTS.override.md`, `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD`) and `.pi/SYSTEM.md` / `.pi/APPEND_SYSTEM.md`. When a group applies another instruction (for example `RULES.md`) and no context file at the project root mentions its path, `apply` warns. It offers to append one reference line per instruction (`- 作業を始める前に \`RULES.md\` を読み、その指示に従うこと。`) to the root context file Pi prefers (`AGENTS.override.md` first); if no root context file exists, it offers to create `AGENTS.md`. The offer is a separate default-no approval; declining still applies the other files. An existing file is backed up privately before the append. `--dry-run` prints the same warning and diff. No reference is offered when the file Pi prefers is a symbolic link, not a regular file, not UTF-8, or larger than 1 MB, or when the same group also places a root context file (matched case-insensitively); the warning then shows the line to add yourself. Only the project root is checked; parent directories and the Pi agent directory are not.
|
|
38
|
+
|
|
39
|
+
`migrate --dry-run` and `migrate --resolve --dry-run` are read-only and may run without a TTY. A mutating `migrate` or `migrate --resolve` requires both stdin and stdout to be a TTY. Reviews print the complete masked diff, then ask for whole-review approval (default no), a separate default-no approval for each sensitive path, and a final default-no confirmation. There is no `--yes` flag. Refusal, EOF, Ctrl-C, blocked state, usage errors, and non-TTY mutation exit with status 1; ready dry-runs, successful operations and a resolution that is not needed exit with status 0.
|
|
40
|
+
|
|
41
|
+
Ordinary `migrate` adopts only eligible existing `sources` content and keeps a verified backup. If an internal migration lock or resolution record blocks it, the CLI points to `migrate --resolve --dry-run`; do not manually remove internal locks. Resolution is limited to eligible post-switch adopted-source edits: it confirms the reviewed edit, preserves source bytes, retains the verified backup, and removes only owned internal migration leftovers. `migrate --resolve` never falls back to ordinary migration. It does not rewrite the catalog, sources or items, distribute resources, or delete items. A blocked review stops while preserving edits and remaining artifacts; a new review may be needed after a cleanup interruption. Never use an older `copi` binary while resolution artifacts remain. v4 journals do not have the start inventory required for this procedure and remain on the existing recovery/hold-stop path. A migration backup is not general history or a restore feature. Migration, item deletion, main integration and publishing require separate approval. Migration (M) and items deletion (D) each require separate approval from one another.
|
|
42
|
+
|
|
43
|
+
### Migration support limits and safe-stop guide
|
|
44
|
+
|
|
45
|
+
R4 migration is adopt-only. `copi migrate --dry-run` may show a `ready` preview for an item backed only by legacy `items`, but items-only content and new `sources` additions are not supported. Commit rejects such a plan before writing; a `ready` preview is not proof that migration can be committed. Migration succeeds only when every selected payload can be adopted from eligible content already in `sources`.
|
|
46
|
+
|
|
47
|
+
The packaged native source-placement helper is currently built for macOS arm64. Every product entry point that needs exclusive native placement declares that requirement and performs a read-only check for the supported platform, packaged helper, and filesystem capability before creating or changing any library path, transaction lock, stage, or journal. If the platform or helper is unsupported, the filesystem does not advertise the required operation, or its capability cannot be determined, the request stops before the first filesystem write; no ordinary rename or copy fallback is used. For a library that does not yet exist, the filesystem check uses its nearest existing directory ancestor. This placement limit does not by itself prohibit read-only commands.
|
|
48
|
+
|
|
49
|
+
`StorageTransactions.run` requires TypeScript callers to explicitly declare whether native placement is required. The internal `#commitNewItems` boundary repeats the check so a direct adapter call from JavaScript cannot bypass it by omitting the option or passing `false`. Such an internal misuse may briefly acquire a transaction lock and root lease before rejection; it writes no content payload, stage, or journal, then cleans the lock and releases the lease. Product entry points preflight before that lock or lease is created.
|
|
50
|
+
|
|
51
|
+
During an operation, do not move, rename, or replace the library root, `sources/`, or a saved item's management directory. This rule is about management directories, not ordinary content edits: editing a regular source file, an editor's file replacement, and skill file additions and removals are supported ordinary-edit cases. Do not treat a managed-directory move or replacement as an ordinary edit.
|
|
52
|
+
|
|
53
|
+
For a migration stopped by an internal transaction lock or resolution record, first run `copi migrate --resolve --dry-run`. It is read-only. If it reports `ready`, review the complete masked diff; only then may you run `copi migrate --resolve` for the separate default-no whole-review, per-sensitive-path, and final confirmations. If the preview reports `blocked`, leave the edits and remaining artifacts in place. A v4 journal lacks the start inventory; unknown journal versions and incomplete or malformed states that the current build cannot verify remain held. An interrupted cleanup is eligible only when the current build explicitly verifies it and presents a `ready` review.
|
|
54
|
+
|
|
55
|
+
If rollback leaves an existing `.migration-backups/` namespace, ordinary migration prepare is blocked because the backup is preserved and not adopted automatically. This is not a retry or restore path: never manually remove, move, or restore a migration backup or move the library contents. An offline preservation check of a real library is a separate read-only activity requiring separate explicit approval for that exact scope; neither a dry-run nor an earlier migration approval grants it. Such a check is not authorization to alter, move, restore, or delete anything, and it must not be reported as a successful migration.
|
|
56
|
+
|
|
57
|
+
Do not hand-edit or remove internal migration-management files: `.copi-transaction` stage/journal files, `.copi-transaction-recovery` claims, or `.copi-migration-resolution.json`. A stage/journal rehash is a known LIMIT, not proof of authorization or custody; same-user manual changes to these internal files and manual edits to a completed resolution record are outside the protection boundary. `privateRevisionHash` is checked only against a same-process issued plan at commit; it is not a durable authentication key. The claim-absent/record-present window after claim removal and before completed-record unlink remains unsafe for older binaries; do not run one until all resolution artifacts are gone.
|
|
58
|
+
|
|
59
|
+
If ordinary source edits occur during interrupted cleanup, recovery can hold-stop and preserve them. When a further pass is eligible, it requires a fresh review and new generation with fresh whole-review, per-sensitive-path, and final approvals; never replay an earlier approval. Out-of-scope changes remain held rather than overwritten.
|
|
60
|
+
|
|
61
|
+
Groups contain resource IDs, not merged documents. The same saved resource may belong to multiple groups. Import and apply copy files byte-for-byte; they do not automatically split, rewrite, merge, or run resource contents. It does not install dependencies or apply global-only resources to a project. Review every proposed change.
|
|
62
|
+
|
|
63
|
+
## Editing and refreshing saved copies
|
|
64
|
+
|
|
65
|
+
A typical flow is `setup` → `manage` → select a resource by name → **Edit saved text** → **Rename display name** → **Refresh from source** → **Manage groups** → **Apply group**. Each change has its own review/confirmation; selecting a menu entry is not permission to save.
|
|
66
|
+
|
|
67
|
+
The terminal editor works only on existing registered UTF-8 files inside the saved copy, up to **1_000_000 bytes** (1 MB), including the resulting edited bytes. Binary/NUL content, unsupported encodings and larger files are refused with a reason. It supports Japanese text, multiline bracketed paste, blank lines, cursor movement and scrolling:
|
|
68
|
+
|
|
69
|
+
- `Ctrl-S`: submit the in-memory text for masked diff review; this does **not** save yet.
|
|
70
|
+
- `Esc`, `Ctrl-C` or `Ctrl-D`: cancel; interruption leaves unconfirmed changes unsaved.
|
|
71
|
+
- `Enter`: newline; arrow keys: move; `Home` / `End`: line start/end.
|
|
72
|
+
- `Ctrl-Home` / `Ctrl-End`: document start/end; `PgUp` / `PgDn`: scroll; Backspace / Delete: remove text.
|
|
73
|
+
|
|
74
|
+
Saving requires diff approval, fresh approval for each detected sensitive file in the fixed prepared bytes, and final confirmation. Normal views and diffs are masked. Before editing a detected sensitive file, a separate individual approval warns that its **raw text** may expose secrets on the screen, scrollback or terminal recordings. That exposure approval is not save approval. Detection is heuristic and may miss secrets. A checksum mismatch or concurrent change stops saving rather than silently repairing or overwriting the saved copy.
|
|
75
|
+
|
|
76
|
+
Renaming changes the display name only: the internal ID, file names, body (including embedded agent names), destination and group references stay unchanged. Destination changes are a separate operation. Text editing cannot add/delete files or rename internal files.
|
|
77
|
+
|
|
78
|
+
Refresh reads an explicitly permitted same-kind source (a directory tree for a skill; a regular file for other kinds). If the original is unavailable, select a new absolute source path or cancel; the saved copy remains available. No HOME or parent tree is searched implicitly. Registered package origins can be rediscovered only in explicitly selected and authorized locations; arbitrary source paths gain no exclusion exception. After masked diff review, inspection and individual sensitive-file approvals, refresh replaces the **whole** saved copy, including bundle file additions/removals and mode changes. All copi-only edits are lost, not merged. ID, display name, savedAt, destination and **all** group memberships are retained. Neither editing nor refresh writes back to the original.
|
|
79
|
+
|
|
80
|
+
There is **no change history, restore feature or automatic merge**. Private apply backups for replaced project files are not saved-copy version history. Applying global settings, automatic dependency installation and content transformation remain outside scope.
|
|
81
|
+
|
|
82
|
+
Japanese/paste/caret functionality is covered by automated CLI/PTY tests; real-device IME, paste and visual caret-width verification is outside this acceptance scope and has not been performed. The minimum Node 22.19.0 version itself and other operating systems have not been verified by these checks.
|
|
31
83
|
|
|
32
84
|
## Private storage and sensitive data
|
|
33
85
|
|
|
34
|
-
Snapshots and the catalog are stored under `~/.copi/` with owner-only directory/file permissions. `copi` copies the selected source into its library; it does not modify or delete the original. Snapshots remain available if the original is later removed. The copied content retains its original license and ownership; the MIT license for `copi` does not apply to imported resources.
|
|
86
|
+
Snapshots and the catalog are stored under `~/.copi/` with owner-only directory/file permissions: saved files are private `0600`. Original source modes are retained separately in snapshot descriptors/catalog reads and reproduced at the apply destination, not on the private saved inode. `copi` copies the selected source into its library; it does not modify or delete the original. Snapshots remain available if the original is later removed. The copied content retains its original license and ownership; the MIT license for `copi` does not apply to imported resources.
|
|
35
87
|
|
|
36
88
|
Entire directories named `auth` are excluded; authentication-specific code may be missing, but other supported files in the resource are retained. Files named `auth.json`, `mcp-auth.json`, or `oauth.json`, `.env*` paths (including `.envrc` and `.env-production`), and repository/dependency/cache/history paths such as `.git`, `node_modules`, `.cache`, `.npm`, `cache`, `caches`, `history`, and `sessions` are excluded by the current resource filters. Do not use `copi` as a substitute for reviewing the files selected for copying.
|
|
37
89
|
|
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"artifact": "rename-exclusive-arm64",
|
|
3
|
+
"sha256": "a8101d6b970dca67c3a3243b1bf49286ce4d26e463529e0bcca244704c98ae02",
|
|
4
|
+
"architecture": "arm64",
|
|
5
|
+
"platform": "darwin",
|
|
6
|
+
"protocolVersion": 1,
|
|
7
|
+
"deploymentTarget": "10.12",
|
|
8
|
+
"compiler": "/usr/bin/clang",
|
|
9
|
+
"compilerVersion": "Apple clang version 17.0.0 (clang-1700.6.3.2)",
|
|
10
|
+
"sdkPath": "/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk",
|
|
11
|
+
"sdkVersion": "26.2"
|
|
12
|
+
}
|
package/dist/src/apply.d.ts
CHANGED
|
@@ -23,17 +23,25 @@ export interface ApplyCollision {
|
|
|
23
23
|
path: string;
|
|
24
24
|
itemIds: string[];
|
|
25
25
|
}
|
|
26
|
+
export interface ApplyReferenceProposal {
|
|
27
|
+
path: string;
|
|
28
|
+
status: "new" | "append";
|
|
29
|
+
instructionPaths: string[];
|
|
30
|
+
preview: ApplyPreview;
|
|
31
|
+
}
|
|
26
32
|
export interface ApplyPlan {
|
|
27
33
|
projectDir: string;
|
|
28
34
|
files: ApplyFilePlan[];
|
|
29
35
|
collisions: ApplyCollision[];
|
|
30
36
|
warnings: InspectionWarning[];
|
|
31
37
|
skippedItems: string[];
|
|
38
|
+
referenceProposal?: ApplyReferenceProposal;
|
|
32
39
|
}
|
|
33
40
|
export interface ApplyDecisions {
|
|
34
41
|
collisions?: Record<string, string>;
|
|
35
42
|
existing?: Record<string, "keep" | "replace" | "abort">;
|
|
36
43
|
secretApprovals?: string[];
|
|
44
|
+
addReference?: boolean;
|
|
37
45
|
}
|
|
38
46
|
export interface ApplyBackup {
|
|
39
47
|
path: string;
|
|
@@ -46,6 +54,7 @@ export interface ApplyResult {
|
|
|
46
54
|
skippedItems: string[];
|
|
47
55
|
backups: ApplyBackup[];
|
|
48
56
|
cancelled: boolean;
|
|
57
|
+
referenceAdded?: string;
|
|
49
58
|
}
|
|
50
59
|
export declare function planApply(libraryDir: string, groupId: string, projectDir: string): Promise<ApplyPlan>;
|
|
51
60
|
export declare function executeApply(libraryDir: string, plan: ApplyPlan, decisions: ApplyDecisions): Promise<ApplyResult>;
|
package/dist/src/apply.js
CHANGED
|
@@ -3,8 +3,10 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { access, chmod, lstat, mkdir, open, readFile, realpath, rename, rm } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { createTwoFilesPatch } from "diff";
|
|
6
|
-
import {
|
|
6
|
+
import { withSavedLibrary } from "./catalog.js";
|
|
7
7
|
import { maskSensitiveText } from "./inspect.js";
|
|
8
|
+
import { appendReferenceLines, chooseReferenceTarget, contextFileNames, isAutoLoadedInstruction, referenceLine, unreferencedPaths } from "./instruction-reference.js";
|
|
9
|
+
import { assertNoNameConflicts } from "./names.js";
|
|
8
10
|
const maxInspectionBytes = 1_000_000;
|
|
9
11
|
const excludedNames = new Set([".git", ".cache", ".npm", "node_modules", "cache", "caches", "history", "sessions", "auth"]);
|
|
10
12
|
const planState = new WeakMap();
|
|
@@ -168,20 +170,91 @@ function targetPath(item, filePath, fileCount) {
|
|
|
168
170
|
throw new Error(`A file resource has an invalid snapshot layout: ${item.name}`);
|
|
169
171
|
return item.destination.path;
|
|
170
172
|
}
|
|
173
|
+
async function readRootContextFiles(root) {
|
|
174
|
+
const files = new Map();
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
for (const name of contextFileNames) {
|
|
177
|
+
const target = path.join(root, name);
|
|
178
|
+
const stat = await maybeLstat(target);
|
|
179
|
+
if (!stat || seen.has(stat.ino))
|
|
180
|
+
continue;
|
|
181
|
+
seen.add(stat.ino);
|
|
182
|
+
if (!stat.isFile()) {
|
|
183
|
+
files.set(name, "unusable");
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const bytes = await readFile(target);
|
|
187
|
+
files.set(name, textFrom(bytes) === undefined ? "unusable" : { bytes, mode: stat.mode & 0o777, sha256: sha256(bytes) });
|
|
188
|
+
}
|
|
189
|
+
return files;
|
|
190
|
+
}
|
|
191
|
+
async function planInstructionReference(root, instructionPaths, candidates, warnings) {
|
|
192
|
+
const targets = instructionPaths.filter((value) => !isAutoLoadedInstruction(value));
|
|
193
|
+
if (targets.length === 0)
|
|
194
|
+
return undefined;
|
|
195
|
+
const current = await readRootContextFiles(root);
|
|
196
|
+
const lowerNames = new Set(contextFileNames.map((name) => name.toLowerCase()));
|
|
197
|
+
const planned = [...candidates.keys()].filter((key) => !key.includes("/") && lowerNames.has(key.toLowerCase()));
|
|
198
|
+
const plannedLower = new Set(planned.map((key) => key.toLowerCase()));
|
|
199
|
+
const texts = [];
|
|
200
|
+
for (const key of planned) {
|
|
201
|
+
for (const option of candidates.get(key)) {
|
|
202
|
+
const text = textFrom(option.bytes);
|
|
203
|
+
if (text !== undefined)
|
|
204
|
+
texts.push(text);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const name of contextFileNames) {
|
|
208
|
+
if (plannedLower.has(name.toLowerCase()))
|
|
209
|
+
continue;
|
|
210
|
+
const file = current.get(name);
|
|
211
|
+
if (file && file !== "unusable")
|
|
212
|
+
texts.push(textFrom(file.bytes));
|
|
213
|
+
}
|
|
214
|
+
const missing = unreferencedPaths(targets, texts);
|
|
215
|
+
if (missing.length === 0)
|
|
216
|
+
return undefined;
|
|
217
|
+
const targetName = chooseReferenceTarget([...current.keys(), ...planned]);
|
|
218
|
+
const existing = current.get(targetName);
|
|
219
|
+
const manual = planned.length > 0 || existing === "unusable";
|
|
220
|
+
for (const value of missing) {
|
|
221
|
+
const how = manual
|
|
222
|
+
? `Add this line to ${targetName} yourself: ${referenceLine(value)}`
|
|
223
|
+
: `Approve the optional reference line for ${targetName} during apply, or add it yourself: ${referenceLine(value)}`;
|
|
224
|
+
addWarning(warnings, "instruction-not-loaded", `Pi does not load this instruction automatically and no root context file mentions it. ${how}`, value);
|
|
225
|
+
}
|
|
226
|
+
if (manual)
|
|
227
|
+
return undefined;
|
|
228
|
+
const before = existing; // "unusable" always sets manual, which returned above
|
|
229
|
+
const bytes = Buffer.from(appendReferenceLines(before ? textFrom(before.bytes) : undefined, missing), "utf8");
|
|
230
|
+
const candidate = { itemId: "copi-reference", bytes, mode: before?.mode ?? 0o644, sha256: sha256(bytes), sensitive: false, preview: { kind: "binary", summary: "" } };
|
|
231
|
+
candidate.preview = previewFor(targetName, candidate, before);
|
|
232
|
+
return {
|
|
233
|
+
proposal: { path: targetName, status: before ? "append" : "new", instructionPaths: missing, preview: candidate.preview },
|
|
234
|
+
path: targetName,
|
|
235
|
+
candidate,
|
|
236
|
+
before,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
171
239
|
function candidateKey(candidate) {
|
|
172
240
|
return `${candidate.sha256}:${candidate.mode}`;
|
|
173
241
|
}
|
|
174
242
|
export async function planApply(libraryDir, groupId, projectDir) {
|
|
175
|
-
|
|
243
|
+
return withSavedLibrary(libraryDir, async (state) => planApplyLocked(state, groupId, projectDir));
|
|
244
|
+
}
|
|
245
|
+
async function planApplyLocked(state, groupId, projectDir) {
|
|
246
|
+
const catalog = state.catalog;
|
|
247
|
+
assertNoNameConflicts(catalog);
|
|
176
248
|
const group = catalog.groups.find((item) => item.id === groupId);
|
|
177
249
|
if (!group)
|
|
178
250
|
throw new Error(`Unknown group: ${groupId}`);
|
|
179
|
-
const storeRoot =
|
|
251
|
+
const storeRoot = state.session.root;
|
|
180
252
|
const project = await safeProjectRoot(projectDir);
|
|
181
253
|
const warnings = [];
|
|
182
254
|
const skippedItems = [];
|
|
183
255
|
const candidates = new Map();
|
|
184
256
|
const existing = new Map();
|
|
257
|
+
const instructionPaths = [];
|
|
185
258
|
for (const itemId of group.itemIds) {
|
|
186
259
|
const item = catalog.items.find((entry) => entry.id === itemId);
|
|
187
260
|
if (!item)
|
|
@@ -191,7 +264,7 @@ export async function planApply(libraryDir, groupId, projectDir) {
|
|
|
191
264
|
addWarning(warnings, "global-only", "This resource is saved for reference and cannot be applied to a project.", item.name);
|
|
192
265
|
continue;
|
|
193
266
|
}
|
|
194
|
-
const snapshotFiles =
|
|
267
|
+
const snapshotFiles = state.files.get(item.id);
|
|
195
268
|
for (const snapshot of snapshotFiles) {
|
|
196
269
|
if (isExcludedSnapshotPath(snapshot.path)) {
|
|
197
270
|
addWarning(warnings, "private-path-skipped", "Authentication, environment, history, and cache files are not applied.", snapshot.path);
|
|
@@ -199,8 +272,10 @@ export async function planApply(libraryDir, groupId, projectDir) {
|
|
|
199
272
|
}
|
|
200
273
|
const destination = targetPath(item, snapshot.path, snapshotFiles.length);
|
|
201
274
|
const relativePath = path.posix.normalize(destination);
|
|
202
|
-
if (!safeRelativePath(relativePath))
|
|
203
|
-
throw new Error(`Unsafe project destination in catalog: ${relativePath}`);
|
|
275
|
+
if (!safeRelativePath(relativePath) || isExcludedSnapshotPath(relativePath))
|
|
276
|
+
throw new Error(`Unsafe or private/excluded project destination in catalog: ${relativePath}`);
|
|
277
|
+
if (item.kind === "instruction")
|
|
278
|
+
instructionPaths.push(relativePath);
|
|
204
279
|
const fileCandidates = candidates.get(relativePath) ?? [];
|
|
205
280
|
const candidate = {
|
|
206
281
|
itemId: item.id,
|
|
@@ -246,14 +321,22 @@ export async function planApply(libraryDir, groupId, projectDir) {
|
|
|
246
321
|
alternatives: uniqueCandidates.map(({ itemId, sensitive, preview }) => ({ itemId, sensitive, preview })),
|
|
247
322
|
});
|
|
248
323
|
}
|
|
249
|
-
const
|
|
324
|
+
const reference = await planInstructionReference(project.canonical, instructionPaths, candidates, warnings);
|
|
325
|
+
if (reference)
|
|
326
|
+
existing.set(reference.path, reference.before);
|
|
327
|
+
const plan = reference
|
|
328
|
+
? { projectDir: project.canonical, files, collisions, warnings, skippedItems, referenceProposal: reference.proposal }
|
|
329
|
+
: { projectDir: project.canonical, files, collisions, warnings, skippedItems };
|
|
330
|
+
await state.session.assertRevision(state.revision);
|
|
250
331
|
planState.set(plan, {
|
|
332
|
+
revision: state.revision,
|
|
251
333
|
libraryRoot: storeRoot,
|
|
252
334
|
projectRoot: project.canonical,
|
|
253
335
|
requestedProjectDir: project.requested,
|
|
254
336
|
candidates: new Map([...candidates].map(([key, values]) => [key, [...new Map(values.map((candidate) => [candidateKey(candidate), candidate])).values()]])),
|
|
255
337
|
existing,
|
|
256
338
|
skippedItems: [...skippedItems],
|
|
339
|
+
...(reference ? { reference: { path: reference.path, candidate: reference.candidate } } : {}),
|
|
257
340
|
});
|
|
258
341
|
return plan;
|
|
259
342
|
}
|
|
@@ -372,11 +455,16 @@ async function writeSnapshotFile(root, relativePath, candidate) {
|
|
|
372
455
|
}
|
|
373
456
|
}
|
|
374
457
|
export async function executeApply(libraryDir, plan, decisions) {
|
|
458
|
+
return withSavedLibrary(libraryDir, async (state) => executeApplyLocked(state, plan, decisions));
|
|
459
|
+
}
|
|
460
|
+
async function executeApplyLocked(state, plan, decisions) {
|
|
375
461
|
const internal = planState.get(plan);
|
|
376
462
|
if (!internal)
|
|
377
463
|
throw new Error("Apply plan is not a verified plan created by planApply.");
|
|
378
|
-
if (
|
|
464
|
+
if (state.session.root !== internal.libraryRoot)
|
|
379
465
|
throw new Error("Apply plan belongs to a different copi library.");
|
|
466
|
+
assertNoNameConflicts(state.catalog);
|
|
467
|
+
await state.session.assertRevision(internal.revision);
|
|
380
468
|
if (await realpath(internal.requestedProjectDir) !== internal.projectRoot)
|
|
381
469
|
throw new Error("Project destination changed after planning.");
|
|
382
470
|
const operations = [];
|
|
@@ -418,6 +506,14 @@ export async function executeApply(libraryDir, plan, decisions) {
|
|
|
418
506
|
else
|
|
419
507
|
operations.push({ path: relativePath, candidate });
|
|
420
508
|
}
|
|
509
|
+
if (decisions.addReference === true) {
|
|
510
|
+
if (!internal.reference)
|
|
511
|
+
throw new Error("The apply plan has no reference proposal to add.");
|
|
512
|
+
const before = internal.existing.get(internal.reference.path);
|
|
513
|
+
operations.push(before
|
|
514
|
+
? { path: internal.reference.path, candidate: internal.reference.candidate, before }
|
|
515
|
+
: { path: internal.reference.path, candidate: internal.reference.candidate });
|
|
516
|
+
}
|
|
421
517
|
if (cancelled)
|
|
422
518
|
return { written: [], kept: [], skippedItems: [...internal.skippedItems], backups: [], cancelled: true };
|
|
423
519
|
for (const operation of operations) {
|
|
@@ -428,6 +524,7 @@ export async function executeApply(libraryDir, plan, decisions) {
|
|
|
428
524
|
}
|
|
429
525
|
await assertWritableParent(internal.projectRoot, operation.path);
|
|
430
526
|
}
|
|
527
|
+
await state.session.assertRevision(internal.revision);
|
|
431
528
|
const backups = await createBackupSet(internal.libraryRoot, operations);
|
|
432
529
|
const written = [];
|
|
433
530
|
for (const operation of operations) {
|
|
@@ -436,8 +533,12 @@ export async function executeApply(libraryDir, plan, decisions) {
|
|
|
436
533
|
if ((current?.sha256 ?? undefined) !== (expected?.sha256 ?? undefined) || (current?.mode ?? undefined) !== (expected?.mode ?? undefined)) {
|
|
437
534
|
throw new Error(`Destination changed during apply: ${operation.path}`);
|
|
438
535
|
}
|
|
536
|
+
await state.session.assertRevision(internal.revision);
|
|
439
537
|
await writeSnapshotFile(internal.projectRoot, operation.path, operation.candidate);
|
|
440
538
|
written.push(operation.path);
|
|
441
539
|
}
|
|
442
|
-
|
|
540
|
+
const referenceAdded = internal.reference && written.includes(internal.reference.path) ? internal.reference.path : undefined;
|
|
541
|
+
return referenceAdded
|
|
542
|
+
? { written, kept, skippedItems: [...internal.skippedItems], backups, cancelled: false, referenceAdded }
|
|
543
|
+
: { written, kept, skippedItems: [...internal.skippedItems], backups, cancelled: false };
|
|
443
544
|
}
|
package/dist/src/catalog.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { type StorageRevision, type StorageSession } from "./storage-transaction.ts";
|
|
2
|
+
import type { Catalog, InspectionResult, LegacySavedItem, ResourceCandidate, SavedItem, SnapshotFileContents, StoredCatalog } from "./types.ts";
|
|
2
3
|
export interface ImportOptions {
|
|
3
4
|
confirmed: boolean;
|
|
4
5
|
groupIds: string[];
|
|
6
|
+
inspection?: PreparedInspection;
|
|
7
|
+
approvedSensitivePaths?: readonly string[];
|
|
5
8
|
}
|
|
6
9
|
declare const preparedResourceBrand: unique symbol;
|
|
7
10
|
export interface PreparedResource {
|
|
@@ -28,12 +31,26 @@ export interface PreparedImportOptions extends ImportOptions {
|
|
|
28
31
|
approvedSensitivePaths: readonly string[];
|
|
29
32
|
}
|
|
30
33
|
export declare function newCatalog(): Catalog;
|
|
34
|
+
export declare function parseStoredCatalog(value: unknown): StoredCatalog;
|
|
35
|
+
export declare function assertModernCatalog(catalog: StoredCatalog): asserts catalog is Catalog;
|
|
31
36
|
export declare function prepareResource(candidate: ResourceCandidate): Promise<PreparedResource>;
|
|
32
37
|
export declare function readPreparedResource(prepared: PreparedResource): PreparedResourceContents;
|
|
33
|
-
export declare function
|
|
38
|
+
export declare function catalogBytes(catalog: Catalog): Buffer;
|
|
39
|
+
export declare function loadCatalog(libraryDir: string): Promise<StoredCatalog>;
|
|
40
|
+
export interface SavedLibraryState {
|
|
41
|
+
catalog: Catalog;
|
|
42
|
+
revision: StorageRevision;
|
|
43
|
+
files: Map<string, SnapshotFileContents[]>;
|
|
44
|
+
session: StorageSession;
|
|
45
|
+
}
|
|
46
|
+
/** Internal seam: a single lock, checked catalog and all snapshots; no recursive reader locking. */
|
|
47
|
+
export declare function withSavedLibrary<T>(libraryDir: string, operation: (state: SavedLibraryState) => Promise<T>): Promise<T>;
|
|
34
48
|
export declare function saveCatalog(libraryDir: string, catalog: Catalog): Promise<void>;
|
|
35
49
|
export declare function importPreparedResource(libraryDir: string, prepared: PreparedResource, options: PreparedImportOptions): Promise<SavedItem | null>;
|
|
36
50
|
export declare function importResource(libraryDir: string, candidate: ResourceCandidate, options: ImportOptions): Promise<SavedItem | null>;
|
|
37
|
-
export declare function readSnapshotFiles(libraryDir: string, item: SavedItem): Promise<SnapshotFileContents[]>;
|
|
38
|
-
|
|
51
|
+
export declare function readSnapshotFiles(libraryDir: string, item: SavedItem | LegacySavedItem): Promise<SnapshotFileContents[]>;
|
|
52
|
+
/** Persist staged, explicit name-only changes together, so cancellation never partially resolves names. */
|
|
53
|
+
export declare function saveNameChanges(libraryDir: string, before: Catalog, after: Catalog): Promise<void>;
|
|
54
|
+
export declare function renameSavedItem(libraryDir: string, itemId: string, name: string): Promise<SavedItem>;
|
|
55
|
+
export declare function listSnapshots(libraryDir: string): Promise<Array<LegacySavedItem | SavedItem>>;
|
|
39
56
|
export {};
|