codecartographer-pi 0.17.0 → 0.18.0
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/.codecarto/GUIDE.md +3 -3
- package/.codecarto/findings/architecture/SKILL.md +1 -0
- package/.codecarto/findings/contracts/SKILL.md +1 -0
- package/.codecarto/findings/defect-scan/SKILL.md +15 -1
- package/.codecarto/findings/defect-scan/passes/01-logic-and-correctness.md +1 -1
- package/.codecarto/findings/defect-scan/passes/02-error-handling.md +1 -1
- package/.codecarto/findings/defect-scan/passes/03-concurrency-and-resources.md +1 -1
- package/.codecarto/findings/defect-scan/passes/04-security-and-trust.md +1 -1
- package/.codecarto/findings/defect-scan/passes/05-api-contract-violations.md +2 -1
- package/.codecarto/findings/defect-scan/passes/06-config-and-environment.md +1 -1
- package/.codecarto/findings/defect-scan-mechanical/SKILL.md +3 -2
- package/.codecarto/findings/defect-scan-semantic/SKILL.md +2 -0
- package/.codecarto/findings/porting/SKILL.md +2 -1
- package/.codecarto/findings/protocols/SKILL.md +1 -0
- package/.codecarto/findings/reimplementation-spec/SKILL.md +1 -1
- package/.codecarto/skills/spec-delta-application/SKILL.md +1 -1
- package/.codecarto/templates/amendment.yaml +3 -3
- package/.codecarto/templates/architecture-map.md +1 -1
- package/.codecarto/templates/defect-report.md +23 -0
- package/.codecarto/templates/mechanical-defects.md +22 -0
- package/.codecarto/templates/reverse-engineering-bundle.md +2 -2
- package/.codecarto/templates/semantic-defects.md +26 -0
- package/.codecarto/templates/spike-report.md +2 -2
- package/.codecarto/workflow/VALIDATE.md +1 -1
- package/.codecarto/workflow/pipeline-defect-scan.yaml +2 -0
- package/.codecarto/workflow/pipeline-full-with-audit.yaml +3 -1
- package/.codecarto/workflow/pipeline-full-with-deep-audit.yaml +5 -1
- package/.codecarto/workflow/pipeline-scout-first.yaml +5 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +14 -10
- package/agent-skill/codecartographer/SKILL.md +1 -1
- package/agent-skill/codecartographer/references/deep-audit-synthesis.md +4 -1
- package/agent-skill/codecartographer/references/library.md +3 -3
- package/agent-skill/codecartographer/references/orchestration.md +1 -1
- package/agent-skill/codecartographer/references/phase-recovery.md +1 -1
- package/dist/core/amendment.d.ts +5 -0
- package/dist/core/amendment.js +23 -4
- package/dist/core/completion.d.ts +5 -0
- package/dist/core/completion.js +18 -2
- package/dist/core/dashboard.js +5 -3
- package/dist/core/findings.d.ts +59 -0
- package/dist/core/findings.js +145 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/library.d.ts +139 -1
- package/dist/core/library.js +291 -40
- package/dist/core/orchestrator-config.d.ts +6 -0
- package/dist/core/orchestrator-config.js +2 -0
- package/dist/core/pipeline.js +15 -0
- package/dist/core/prompts.js +1 -1
- package/dist/core/status.d.ts +2 -1
- package/dist/core/status.js +33 -11
- package/dist/core/types.d.ts +6 -0
- package/dist/core/utils.d.ts +14 -0
- package/dist/core/utils.js +30 -0
- package/dist/core/workspace.d.ts +16 -0
- package/dist/core/workspace.js +42 -22
- package/dist/core/yaml.js +19 -4
- package/dist/extensions/codecarto/auto-runner.d.ts +2 -0
- package/dist/extensions/codecarto/broadside-flags.d.ts +7 -2
- package/dist/extensions/codecarto/broadside-flags.js +22 -9
- package/dist/extensions/codecarto/dashboard-narrator.js +1 -1
- package/dist/extensions/codecarto/index.js +379 -20
- package/dist/extensions/codecarto/phase-compaction.js +4 -0
- package/dist/mcp-server/server.js +127 -10
- package/package.json +2 -2
package/dist/core/library.d.ts
CHANGED
|
@@ -75,9 +75,39 @@ export interface LibraryIndex {
|
|
|
75
75
|
namespaces: string[];
|
|
76
76
|
entries: LibraryIndexEntry[];
|
|
77
77
|
}
|
|
78
|
+
/** One older version whose recorded source_repo names a different repository than the newest version's. */
|
|
79
|
+
export interface ProvenanceConflictVersion {
|
|
80
|
+
version: number;
|
|
81
|
+
source_repo: string;
|
|
82
|
+
}
|
|
83
|
+
/** An entry whose version history spans more than one repository. */
|
|
84
|
+
export interface ProvenanceConflict {
|
|
85
|
+
slug: string;
|
|
86
|
+
namespace?: string;
|
|
87
|
+
/** The newest version — the one whose metadata the index reports for the whole entry. */
|
|
88
|
+
latest_version: number;
|
|
89
|
+
/** The source_repo recorded on the newest version, i.e. what index.yaml advertises. */
|
|
90
|
+
source_repo: string;
|
|
91
|
+
/** Older versions that disagree with it, ascending. Versions with missing or unreadable metadata are skipped. */
|
|
92
|
+
disagreeing_versions: ProvenanceConflictVersion[];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* What `reindex` returns: the index it wrote, plus findings that ride on the
|
|
96
|
+
* return value only. `provenance_conflicts` is never serialized into
|
|
97
|
+
* index.yaml or INDEX.md — both shapes are ABI (docs/library-format.md).
|
|
98
|
+
*/
|
|
99
|
+
export interface ReindexResult extends LibraryIndex {
|
|
100
|
+
provenance_conflicts: ProvenanceConflict[];
|
|
101
|
+
}
|
|
78
102
|
export declare function discoverLibrary(libraryPath: string): Promise<LibraryMarker | null>;
|
|
79
103
|
export declare function readMarker(libraryRoot: string): Promise<LibraryMarker | null>;
|
|
80
104
|
export declare function writeMarker(libraryRoot: string, marker: LibraryMarker): Promise<void>;
|
|
105
|
+
/**
|
|
106
|
+
* The level a marker's `visibility` or an entry's `confidentiality` is taken
|
|
107
|
+
* to have when it declares none. It is the default `initLibrary` writes and
|
|
108
|
+
* the default docs/library-format.md gives the entry field.
|
|
109
|
+
*/
|
|
110
|
+
export declare const DEFAULT_VISIBILITY: LibraryVisibility;
|
|
81
111
|
export interface InitLibraryOptions {
|
|
82
112
|
/** Library name (defaults to basename of the path). */
|
|
83
113
|
name?: string;
|
|
@@ -106,6 +136,11 @@ export declare function isValidSlug(slug: string): boolean;
|
|
|
106
136
|
* Caller is responsible for collision handling — derived slugs may already
|
|
107
137
|
* exist in the library and the calling UX (Pi or MCP) is the right place
|
|
108
138
|
* to ask the user about it.
|
|
139
|
+
*
|
|
140
|
+
* A clone's remote URL and its checkout directory derive the same slug
|
|
141
|
+
* (`…/whisper.git`, `git@host:acme/whisper`, and `/path/whisper` all give
|
|
142
|
+
* `whisper`), which is what lets the Pi command switch from recording the
|
|
143
|
+
* directory to recording the remote without renaming anyone's entry.
|
|
109
144
|
*/
|
|
110
145
|
export declare function deriveSlug(sourceRepo: string): string;
|
|
111
146
|
/**
|
|
@@ -153,6 +188,15 @@ export interface PublishOptions {
|
|
|
153
188
|
* the repository genuinely moved (rename, org transfer, host change).
|
|
154
189
|
*/
|
|
155
190
|
allowSourceRepoChange?: boolean;
|
|
191
|
+
/**
|
|
192
|
+
* Permit publishing when the entry's `confidentiality` is more restricted
|
|
193
|
+
* than the library's `visibility` — an `internal` entry into a `shared` or
|
|
194
|
+
* `public` library, a `shared` entry into a `public` one. Off by default:
|
|
195
|
+
* that direction exposes the spec to everyone the library reaches. Set
|
|
196
|
+
* this only when the exposure is intended. It does not change the
|
|
197
|
+
* confidentiality recorded on the entry.
|
|
198
|
+
*/
|
|
199
|
+
allowConfidentialityMismatch?: boolean;
|
|
156
200
|
}
|
|
157
201
|
export interface PublishResult {
|
|
158
202
|
slug: string;
|
|
@@ -162,6 +206,52 @@ export interface PublishResult {
|
|
|
162
206
|
entryDir: string;
|
|
163
207
|
versionDir: string;
|
|
164
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Thrown by `publishEntry` when the entry is more restricted than the library
|
|
211
|
+
* it is headed for. Nothing has been written when this is raised. It carries
|
|
212
|
+
* the two compared levels so a wrapper with a user to ask (Pi) can pose the
|
|
213
|
+
* question from the values rather than by matching the message.
|
|
214
|
+
*/
|
|
215
|
+
export declare class ConfidentialityMismatchError extends Error {
|
|
216
|
+
readonly entryConfidentiality: LibraryVisibility;
|
|
217
|
+
readonly libraryVisibility: LibraryVisibility;
|
|
218
|
+
constructor(message: string, entryConfidentiality: LibraryVisibility, libraryVisibility: LibraryVisibility);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Thrown by `publishEntry` when the target entry's newest version records a
|
|
222
|
+
* `source_repo` that denotes a different repository than the incoming one.
|
|
223
|
+
* Nothing has been written when this is raised. It carries both values so a
|
|
224
|
+
* wrapper with a user to ask (Pi) can pose "did the repository move?" from
|
|
225
|
+
* the values rather than by matching the message.
|
|
226
|
+
*/
|
|
227
|
+
export declare class SourceRepoMismatchError extends Error {
|
|
228
|
+
/** The `source_repo` the entry's newest version records. */
|
|
229
|
+
readonly recorded: string;
|
|
230
|
+
/** The `source_repo` this publish carries. */
|
|
231
|
+
readonly incoming: string;
|
|
232
|
+
constructor(message: string, recorded: string, incoming: string);
|
|
233
|
+
}
|
|
234
|
+
/** What `publishEntry` would do to an entry's version history, without doing it. */
|
|
235
|
+
export interface PublishVersionPreview {
|
|
236
|
+
/** Highest version directory present, or 0 for a new entry. */
|
|
237
|
+
latestVersion: number;
|
|
238
|
+
/** The version the publish would write or update. */
|
|
239
|
+
version: number;
|
|
240
|
+
/** True when a new version directory would be created; false for a metadata-only update. */
|
|
241
|
+
isNewVersion: boolean;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Read-only preview of the version a publish would land on. This is the same
|
|
245
|
+
* content-hash decision `publishEntry` makes (it calls this), so a wrapper
|
|
246
|
+
* that has to describe a publish before performing it — the MCP server's
|
|
247
|
+
* `publish_confirm` refusal — shows what would actually happen. Neither the
|
|
248
|
+
* collision nor the confidentiality guard is evaluated here; those still run
|
|
249
|
+
* on the real publish.
|
|
250
|
+
*/
|
|
251
|
+
export declare function previewPublishVersion(libraryRoot: string, spec: string, ref: {
|
|
252
|
+
slug: string;
|
|
253
|
+
namespace?: string;
|
|
254
|
+
}, opts?: Pick<PublishOptions, "forceNewVersion">): Promise<PublishVersionPreview>;
|
|
165
255
|
export declare function publishEntry(libraryRoot: string, spec: string, input: PublishInput, opts?: PublishOptions): Promise<PublishResult>;
|
|
166
256
|
export interface EntryRef {
|
|
167
257
|
slug: string;
|
|
@@ -182,7 +272,55 @@ export interface ListEntriesFilter {
|
|
|
182
272
|
source_repo?: string;
|
|
183
273
|
}
|
|
184
274
|
export declare function listEntries(libraryRoot: string, filter?: ListEntriesFilter): Promise<LibraryIndexEntry[]>;
|
|
185
|
-
export declare function reindex(libraryRoot: string): Promise<
|
|
275
|
+
export declare function reindex(libraryRoot: string): Promise<ReindexResult>;
|
|
276
|
+
/**
|
|
277
|
+
* Read-only check over the given entries: does every version of each entry
|
|
278
|
+
* record the same repository as its newest version? Comparison goes through
|
|
279
|
+
* `sameSourceRepo`, so spellings of one repository (scheme, `.git`, SCP
|
|
280
|
+
* syntax, casing where safe) do not count as disagreement. A version whose
|
|
281
|
+
* metadata is missing, unreadable, or lacks `source_repo` is skipped rather
|
|
282
|
+
* than reported — the stance the publish guard takes — and an entry whose
|
|
283
|
+
* newest version is unreadable is skipped entirely, since there is nothing to
|
|
284
|
+
* compare against. Never writes.
|
|
285
|
+
*
|
|
286
|
+
* A repository that genuinely moved and was re-published with
|
|
287
|
+
* `allowSourceRepoChange` leaves the same on-disk shape as a collision and is
|
|
288
|
+
* reported the same way; the history alone cannot tell the two apart.
|
|
289
|
+
*/
|
|
290
|
+
export declare function detectProvenanceConflicts(libraryRoot: string, entries: ReadonlyArray<Pick<LibraryIndexEntry, "slug" | "namespace">>): Promise<ProvenanceConflict[]>;
|
|
291
|
+
/** What a Pi publish records as `source_repo`, and where the value came from. */
|
|
292
|
+
export interface ResolvedSourceRepo {
|
|
293
|
+
/** The value to record: a remote's fetch URL verbatim, or the directory itself. */
|
|
294
|
+
source_repo: string;
|
|
295
|
+
/**
|
|
296
|
+
* The git remote the URL was read from (`origin`, or the current branch's
|
|
297
|
+
* upstream remote), or null when the directory was recorded instead — it
|
|
298
|
+
* is not a git work tree, is a subdirectory of one, or has no usable
|
|
299
|
+
* remote.
|
|
300
|
+
*/
|
|
301
|
+
remote: string | null;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Resolve the repository reference a publish from `cwd` should record. The
|
|
305
|
+
* remote is preferred over the path because a path means nothing outside the
|
|
306
|
+
* machine that published it, and because a slug derived from the remote is
|
|
307
|
+
* stable across clones of one repository, which is what lets the collision
|
|
308
|
+
* guard compare something meaningful (#147).
|
|
309
|
+
*
|
|
310
|
+
* Resolution order: `origin`'s fetch URL, else the fetch URL of the remote
|
|
311
|
+
* the current branch tracks, else `cwd`. The remote is consulted only when
|
|
312
|
+
* `cwd` is the root of its work tree. A subdirectory keeps recording its
|
|
313
|
+
* path: every subdirectory of one repository would otherwise resolve to the
|
|
314
|
+
* same URL and the same slug, and the second one published would land as a
|
|
315
|
+
* new version of the first with no guard able to tell — exactly the
|
|
316
|
+
* cross-project append the guard exists to refuse.
|
|
317
|
+
*
|
|
318
|
+
* The URL is stored as git reports it. Spellings of one repository are
|
|
319
|
+
* reconciled at comparison time by `sameSourceRepo`, not here, so the
|
|
320
|
+
* recorded value stays human-readable. Never throws: a missing `git` binary
|
|
321
|
+
* or any git failure falls back to the path.
|
|
322
|
+
*/
|
|
323
|
+
export declare function resolvePublishSourceRepo(cwd: string): Promise<ResolvedSourceRepo>;
|
|
186
324
|
export interface CommitOptions {
|
|
187
325
|
addAll?: boolean;
|
|
188
326
|
}
|
package/dist/core/library.js
CHANGED
|
@@ -15,18 +15,24 @@
|
|
|
15
15
|
// - publishEntry is content-hash idempotent: re-publishing the same
|
|
16
16
|
// spec bytes does not create a new version. Metadata-only changes
|
|
17
17
|
// (headline, tags, capabilities) update the existing latest
|
|
18
|
-
// metadata.yaml in place
|
|
18
|
+
// metadata.yaml in place; the version's recorded provenance is
|
|
19
|
+
// carried forward unless the publish supplies its own.
|
|
19
20
|
// - reindex regenerates index.yaml and INDEX.md from filesystem state.
|
|
20
21
|
// Treat both as derived artifacts; never hand-edit. Resolution
|
|
21
22
|
// recipe for git merge conflicts is documented in
|
|
22
23
|
// docs/library-format.md.
|
|
23
|
-
// -
|
|
24
|
-
//
|
|
24
|
+
// - reindex also reports, on its return value and never in the index
|
|
25
|
+
// files, entries whose versions disagree about source_repo — the shape
|
|
26
|
+
// a slug collision left behind before publish refused cross-project
|
|
27
|
+
// appends. Repair is manual; see the "Provenance conflicts" section.
|
|
28
|
+
// - Git operations (`commitPublish`, `resolvePublishSourceRepo`) shell out
|
|
29
|
+
// to the `git` binary. Failures are non-fatal — the caller decides how to
|
|
30
|
+
// surface them, and the resolver falls back to the directory itself.
|
|
25
31
|
import { createHash } from "node:crypto";
|
|
26
32
|
import { spawn } from "node:child_process";
|
|
27
33
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
28
34
|
import { basename, join, resolve } from "node:path";
|
|
29
|
-
import { isPlainObject, pathExists } from "./utils.js";
|
|
35
|
+
import { canonicalPath, isPlainObject, normalizeForComparison, pathExists } from "./utils.js";
|
|
30
36
|
import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
|
|
31
37
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
32
38
|
export const LIBRARY_MARKER_FILE = ".codecarto-library";
|
|
@@ -86,6 +92,16 @@ function normalizeMarker(raw) {
|
|
|
86
92
|
function isVisibility(v) {
|
|
87
93
|
return v === "internal" || v === "shared" || v === "public";
|
|
88
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* The level a marker's `visibility` or an entry's `confidentiality` is taken
|
|
97
|
+
* to have when it declares none. It is the default `initLibrary` writes and
|
|
98
|
+
* the default docs/library-format.md gives the entry field.
|
|
99
|
+
*/
|
|
100
|
+
export const DEFAULT_VISIBILITY = "internal";
|
|
101
|
+
// Ordered from most to least restricted. An entry may sit in a library at or
|
|
102
|
+
// below its own level; one above it would expose the entry to everyone the
|
|
103
|
+
// library reaches.
|
|
104
|
+
const VISIBILITY_RANK = { internal: 0, shared: 1, public: 2 };
|
|
89
105
|
/**
|
|
90
106
|
* Initialize a CodeCartographer library at the given path: create the
|
|
91
107
|
* directory if needed, write the `.codecarto-library` marker if missing,
|
|
@@ -102,7 +118,7 @@ export async function initLibrary(libraryPath, options = {}) {
|
|
|
102
118
|
schema_version: MARKER_SCHEMA_VERSION,
|
|
103
119
|
name,
|
|
104
120
|
namespaced: options.namespaced ?? false,
|
|
105
|
-
visibility: options.visibility ??
|
|
121
|
+
visibility: options.visibility ?? DEFAULT_VISIBILITY,
|
|
106
122
|
created_at: new Date().toISOString(),
|
|
107
123
|
};
|
|
108
124
|
await writeMarker(libraryPath, marker);
|
|
@@ -122,9 +138,19 @@ export function isValidSlug(slug) {
|
|
|
122
138
|
* Caller is responsible for collision handling — derived slugs may already
|
|
123
139
|
* exist in the library and the calling UX (Pi or MCP) is the right place
|
|
124
140
|
* to ask the user about it.
|
|
141
|
+
*
|
|
142
|
+
* A clone's remote URL and its checkout directory derive the same slug
|
|
143
|
+
* (`…/whisper.git`, `git@host:acme/whisper`, and `/path/whisper` all give
|
|
144
|
+
* `whisper`), which is what lets the Pi command switch from recording the
|
|
145
|
+
* directory to recording the remote without renaming anyone's entry.
|
|
125
146
|
*/
|
|
126
147
|
export function deriveSlug(sourceRepo) {
|
|
127
|
-
|
|
148
|
+
let cleaned = sourceRepo.replace(/\.git$/i, "").replace(/\\/g, "/");
|
|
149
|
+
// SCP shorthand for a repository at the root of a host (`git@host:whisper`)
|
|
150
|
+
// has no slash at all, so the colon is the only separator to split on. Any
|
|
151
|
+
// form with a slash already yields the right trailing segment below.
|
|
152
|
+
if (!cleaned.includes("/"))
|
|
153
|
+
cleaned = cleaned.replace(/^[^:]*:/, "");
|
|
128
154
|
const parts = cleaned.split("/").filter((p) => p.length > 0);
|
|
129
155
|
const last = parts[parts.length - 1] ?? "entry";
|
|
130
156
|
const slug = last
|
|
@@ -180,8 +206,10 @@ export function normalizeSourceRepo(sourceRepo) {
|
|
|
180
206
|
// Windows drive paths. A POSIX absolute path is not: /srv/Repos/tool and
|
|
181
207
|
// /srv/repos/tool are two directories on Linux, and folding them together
|
|
182
208
|
// would hide exactly the cross-project collision this comparison exists to
|
|
183
|
-
// catch. Pi records the analyzed directory as source_repo
|
|
184
|
-
//
|
|
209
|
+
// catch. Pi records the analyzed directory as source_repo whenever it has
|
|
210
|
+
// no git remote to record instead, and every entry Pi published before it
|
|
211
|
+
// resolved remotes holds one, so local paths are a common case here rather
|
|
212
|
+
// than a curiosity.
|
|
185
213
|
return isCaseSensitivePath(s) ? s : s.toLowerCase();
|
|
186
214
|
}
|
|
187
215
|
/** An absolute POSIX path (or a `~` home reference), where case is significant. */
|
|
@@ -193,10 +221,11 @@ export function sameSourceRepo(a, b) {
|
|
|
193
221
|
return normalizeSourceRepo(a) === normalizeSourceRepo(b);
|
|
194
222
|
}
|
|
195
223
|
/**
|
|
196
|
-
* The `source_repo` recorded on an entry
|
|
224
|
+
* The `source_repo` recorded on one version of an entry, or null when it
|
|
197
225
|
* cannot be determined (no metadata, unreadable, or malformed). Null means
|
|
198
226
|
* "unknown", and callers treat unknown as permission to proceed rather than
|
|
199
|
-
* as a mismatch
|
|
227
|
+
* as a mismatch — the publish guard lets the publish through, and conflict
|
|
228
|
+
* detection skips the version.
|
|
200
229
|
*/
|
|
201
230
|
async function readRecordedSourceRepo(libraryRoot, namespace, slug, version) {
|
|
202
231
|
const metaPath = join(versionDir(libraryRoot, namespace, slug, version), METADATA_FILE);
|
|
@@ -213,6 +242,26 @@ async function readRecordedSourceRepo(libraryRoot, namespace, slug, version) {
|
|
|
213
242
|
return null;
|
|
214
243
|
}
|
|
215
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* The `provenance` block recorded on one version of an entry, or undefined
|
|
247
|
+
* when there is none to carry forward (no metadata, unreadable, malformed,
|
|
248
|
+
* or a version that never had the block — a hand-built entry, say). The
|
|
249
|
+
* metadata-only publish branch uses this so an identical re-publish, which
|
|
250
|
+
* neither surface sends `provenance` with, rewrites `metadata.yaml` without
|
|
251
|
+
* dropping what the version's original publish recorded.
|
|
252
|
+
*/
|
|
253
|
+
async function readRecordedProvenance(libraryRoot, namespace, slug, version) {
|
|
254
|
+
const metaPath = join(versionDir(libraryRoot, namespace, slug, version), METADATA_FILE);
|
|
255
|
+
if (!(await pathExists(metaPath)))
|
|
256
|
+
return undefined;
|
|
257
|
+
try {
|
|
258
|
+
const raw = parseSimpleYaml(await readFile(metaPath, "utf8"));
|
|
259
|
+
return normalizeMetadata(raw, { slug, namespace, version }).provenance;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
216
265
|
// ─── Path helpers ───────────────────────────────────────────────────────────
|
|
217
266
|
function entryRoot(libraryRoot, namespace, slug) {
|
|
218
267
|
return namespace ? join(libraryRoot, ENTRIES_DIR, namespace, slug) : join(libraryRoot, ENTRIES_DIR, slug);
|
|
@@ -253,6 +302,64 @@ async function readLatestPointer(entryDir) {
|
|
|
253
302
|
return null;
|
|
254
303
|
}
|
|
255
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Thrown by `publishEntry` when the entry is more restricted than the library
|
|
307
|
+
* it is headed for. Nothing has been written when this is raised. It carries
|
|
308
|
+
* the two compared levels so a wrapper with a user to ask (Pi) can pose the
|
|
309
|
+
* question from the values rather than by matching the message.
|
|
310
|
+
*/
|
|
311
|
+
export class ConfidentialityMismatchError extends Error {
|
|
312
|
+
entryConfidentiality;
|
|
313
|
+
libraryVisibility;
|
|
314
|
+
constructor(message, entryConfidentiality, libraryVisibility) {
|
|
315
|
+
super(message);
|
|
316
|
+
this.name = "ConfidentialityMismatchError";
|
|
317
|
+
this.entryConfidentiality = entryConfidentiality;
|
|
318
|
+
this.libraryVisibility = libraryVisibility;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Thrown by `publishEntry` when the target entry's newest version records a
|
|
323
|
+
* `source_repo` that denotes a different repository than the incoming one.
|
|
324
|
+
* Nothing has been written when this is raised. It carries both values so a
|
|
325
|
+
* wrapper with a user to ask (Pi) can pose "did the repository move?" from
|
|
326
|
+
* the values rather than by matching the message.
|
|
327
|
+
*/
|
|
328
|
+
export class SourceRepoMismatchError extends Error {
|
|
329
|
+
/** The `source_repo` the entry's newest version records. */
|
|
330
|
+
recorded;
|
|
331
|
+
/** The `source_repo` this publish carries. */
|
|
332
|
+
incoming;
|
|
333
|
+
constructor(message, recorded, incoming) {
|
|
334
|
+
super(message);
|
|
335
|
+
this.name = "SourceRepoMismatchError";
|
|
336
|
+
this.recorded = recorded;
|
|
337
|
+
this.incoming = incoming;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Read-only preview of the version a publish would land on. This is the same
|
|
342
|
+
* content-hash decision `publishEntry` makes (it calls this), so a wrapper
|
|
343
|
+
* that has to describe a publish before performing it — the MCP server's
|
|
344
|
+
* `publish_confirm` refusal — shows what would actually happen. Neither the
|
|
345
|
+
* collision nor the confidentiality guard is evaluated here; those still run
|
|
346
|
+
* on the real publish.
|
|
347
|
+
*/
|
|
348
|
+
export async function previewPublishVersion(libraryRoot, spec, ref, opts = {}) {
|
|
349
|
+
const entryDir = entryRoot(libraryRoot, ref.namespace, ref.slug);
|
|
350
|
+
const existingVersions = await listVersionDirs(entryDir);
|
|
351
|
+
const latestVersion = existingVersions.length === 0 ? 0 : existingVersions[existingVersions.length - 1];
|
|
352
|
+
if (latestVersion > 0 && !opts.forceNewVersion) {
|
|
353
|
+
const latestSpecPath = join(versionDir(libraryRoot, ref.namespace, ref.slug, latestVersion), SPEC_FILE);
|
|
354
|
+
if (await pathExists(latestSpecPath)) {
|
|
355
|
+
const existingSpec = await readFile(latestSpecPath, "utf8");
|
|
356
|
+
if (sha256(existingSpec) === sha256(spec)) {
|
|
357
|
+
return { latestVersion, version: latestVersion, isNewVersion: false };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return { latestVersion, version: latestVersion + 1, isNewVersion: true };
|
|
362
|
+
}
|
|
256
363
|
export async function publishEntry(libraryRoot, spec, input, opts = {}) {
|
|
257
364
|
const marker = await readMarker(libraryRoot);
|
|
258
365
|
if (!marker) {
|
|
@@ -272,9 +379,8 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
|
|
|
272
379
|
}
|
|
273
380
|
const namespace = input.namespace;
|
|
274
381
|
const entryDir = entryRoot(libraryRoot, namespace, input.slug);
|
|
275
|
-
const
|
|
276
|
-
const latestVersion =
|
|
277
|
-
const newSpecHash = sha256(spec);
|
|
382
|
+
const preview = await previewPublishVersion(libraryRoot, spec, { slug: input.slug, namespace }, opts);
|
|
383
|
+
const latestVersion = preview.latestVersion;
|
|
278
384
|
// Collision guard. Slugs derive from the trailing path segment of the source
|
|
279
385
|
// repo, so two unrelated projects (acme/whisper and openai/whisper) collapse
|
|
280
386
|
// onto one slug. Without this check the second publish would append its spec
|
|
@@ -286,39 +392,61 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
|
|
|
286
392
|
const recorded = await readRecordedSourceRepo(libraryRoot, namespace, input.slug, latestVersion);
|
|
287
393
|
if (recorded !== null && !sameSourceRepo(recorded, input.source_repo)) {
|
|
288
394
|
const label = namespace ? `${namespace}/${input.slug}` : input.slug;
|
|
289
|
-
throw new
|
|
395
|
+
throw new SourceRepoMismatchError(`Refusing to publish: entry "${label}" v${latestVersion} records source_repo ` +
|
|
290
396
|
`"${recorded}", but this publish carries "${input.source_repo}". Publishing would ` +
|
|
291
397
|
`append this spec to a different project's version history. Publish this project ` +
|
|
292
398
|
`under a distinct slug to shelve it separately, or — if the repository itself ` +
|
|
293
399
|
`moved (rename, org transfer, host change) — re-publish with the source-repo ` +
|
|
294
400
|
`change allowed: allow_source_repo_change on codecarto_publish, ` +
|
|
295
|
-
`allowSourceRepoChange in PublishOptions
|
|
401
|
+
`allowSourceRepoChange in PublishOptions.`, recorded, input.source_repo);
|
|
296
402
|
}
|
|
297
403
|
}
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
404
|
+
// Confidentiality guard. Levels are ordered internal < shared < public. An
|
|
405
|
+
// entry may sit in a library at or below its own level, but one more
|
|
406
|
+
// restricted than its library would be exposed to everyone the library
|
|
407
|
+
// reaches: an internal spec in a public library is a leak. Either side that
|
|
408
|
+
// declares nothing counts as internal — the marker default initLibrary
|
|
409
|
+
// writes, and the entry default docs/library-format.md documents — so a
|
|
410
|
+
// library with no visibility field accepts everything it did before. Like
|
|
411
|
+
// the collision guard this runs ahead of the idempotence branch, so a
|
|
412
|
+
// metadata-only update cannot reclassify an entry past it, and it fails
|
|
413
|
+
// before anything is written.
|
|
414
|
+
const entryConfidentiality = input.confidentiality ?? DEFAULT_VISIBILITY;
|
|
415
|
+
const libraryVisibility = marker.visibility ?? DEFAULT_VISIBILITY;
|
|
416
|
+
if (!opts.allowConfidentialityMismatch && VISIBILITY_RANK[entryConfidentiality] < VISIBILITY_RANK[libraryVisibility]) {
|
|
417
|
+
const label = namespace ? `${namespace}/${input.slug}` : input.slug;
|
|
418
|
+
const declared = input.confidentiality ? "" : " (the default when none is declared)";
|
|
419
|
+
throw new ConfidentialityMismatchError(`Refusing to publish: entry "${label}" has confidentiality "${entryConfidentiality}"${declared}, ` +
|
|
420
|
+
`but library "${marker.name}" has visibility "${libraryVisibility}". Publishing would expose a ` +
|
|
421
|
+
`spec classified "${entryConfidentiality}" to everyone the "${libraryVisibility}" library reaches. ` +
|
|
422
|
+
`Publish it to a library whose visibility is "${entryConfidentiality}" or narrower, declare a ` +
|
|
423
|
+
`confidentiality of "${libraryVisibility}" or wider if the spec may travel that far, or — if ` +
|
|
424
|
+
`this exposure is intended — re-publish with the mismatch allowed: ` +
|
|
425
|
+
`allow_confidentiality_mismatch on codecarto_publish, allowConfidentialityMismatch in PublishOptions.`, entryConfidentiality, libraryVisibility);
|
|
426
|
+
}
|
|
427
|
+
// Content-hash idempotence: if the latest version's spec matches bytes-for-bytes
|
|
428
|
+
// (decided by previewPublishVersion above), update metadata in place and
|
|
429
|
+
// return without bumping the version.
|
|
430
|
+
if (!preview.isNewVersion) {
|
|
301
431
|
const latestVersionDir = versionDir(libraryRoot, namespace, input.slug, latestVersion);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
}
|
|
432
|
+
// buildMetadata writes provenance only when the input carries it, and
|
|
433
|
+
// neither surface sends it on publish — so without this the rewrite
|
|
434
|
+
// would drop the block the version's original publish recorded.
|
|
435
|
+
const provenance = input.provenance ?? (await readRecordedProvenance(libraryRoot, namespace, input.slug, latestVersion));
|
|
436
|
+
const metadata = buildMetadata({ ...input, provenance }, latestVersion);
|
|
437
|
+
await atomicWriteYaml(join(latestVersionDir, METADATA_FILE), metadata);
|
|
438
|
+
if (!opts.skipReindex)
|
|
439
|
+
await reindex(libraryRoot);
|
|
440
|
+
return {
|
|
441
|
+
slug: input.slug,
|
|
442
|
+
namespace,
|
|
443
|
+
version: latestVersion,
|
|
444
|
+
isNewVersion: false,
|
|
445
|
+
entryDir,
|
|
446
|
+
versionDir: latestVersionDir,
|
|
447
|
+
};
|
|
320
448
|
}
|
|
321
|
-
const nextVersion =
|
|
449
|
+
const nextVersion = preview.version;
|
|
322
450
|
const finalVersionDir = versionDir(libraryRoot, namespace, input.slug, nextVersion);
|
|
323
451
|
const stagingDir = `${entryDir}.publish.${process.pid}.${Date.now()}`;
|
|
324
452
|
// Stage all files under a sibling directory, then atomically rename it
|
|
@@ -605,7 +733,10 @@ export async function reindex(libraryRoot) {
|
|
|
605
733
|
};
|
|
606
734
|
await atomicWriteYaml(join(libraryRoot, LIBRARY_INDEX_FILE), index);
|
|
607
735
|
await writeIndexMarkdown(libraryRoot, index, marker);
|
|
608
|
-
|
|
736
|
+
// Reported, not written. The index files above are ABI, so the conflict
|
|
737
|
+
// list travels on the return value only (see ReindexResult).
|
|
738
|
+
const provenance_conflicts = await detectProvenanceConflicts(libraryRoot, entries);
|
|
739
|
+
return { ...index, provenance_conflicts };
|
|
609
740
|
}
|
|
610
741
|
async function buildIndexEntry(libraryRoot, namespace, slug) {
|
|
611
742
|
const entryDir = entryRoot(libraryRoot, namespace, slug);
|
|
@@ -687,7 +818,10 @@ async function writeIndexMarkdown(libraryRoot, index, marker) {
|
|
|
687
818
|
lines.push("");
|
|
688
819
|
lines.push(`_Generated ${index.generated_at}. Do not edit by hand — regenerate with \`codecarto library-reindex\`._`);
|
|
689
820
|
lines.push("");
|
|
690
|
-
|
|
821
|
+
// A single-tenant library has no namespaces but is still one namespace's
|
|
822
|
+
// worth of entries; count once so the noun agrees with the number shown.
|
|
823
|
+
const namespaceCount = index.namespaces.length || 1;
|
|
824
|
+
lines.push(`**${index.entry_count} ${index.entry_count === 1 ? "entry" : "entries"}** across ${namespaceCount} ${namespaceCount === 1 ? "namespace" : "namespaces"}.`);
|
|
691
825
|
lines.push("");
|
|
692
826
|
if (marker.namespaced) {
|
|
693
827
|
const grouped = new Map();
|
|
@@ -725,7 +859,11 @@ async function writeIndexMarkdown(libraryRoot, index, marker) {
|
|
|
725
859
|
await rename(tempPath, path);
|
|
726
860
|
}
|
|
727
861
|
function formatIndexRow(e, namespaced) {
|
|
728
|
-
|
|
862
|
+
// Link to the newest version directory, not `latest/`: the pointer is a
|
|
863
|
+
// one-line regular file (see the module header), so a `latest/` link has
|
|
864
|
+
// nothing to land on when the library is browsed on a forge.
|
|
865
|
+
const entryPath = namespaced && e.namespace ? `${ENTRIES_DIR}/${e.namespace}/${e.slug}` : `${ENTRIES_DIR}/${e.slug}`;
|
|
866
|
+
const pathPart = `${entryPath}/v${e.latest_version}/`;
|
|
729
867
|
const slugLink = `[${escapeMd(e.slug)}](${pathPart})`;
|
|
730
868
|
const headline = escapeMd(e.headline).replace(/\n+/g, " ");
|
|
731
869
|
const tags = e.tags.length === 0 ? "" : e.tags.map(escapeMd).join(", ");
|
|
@@ -737,6 +875,70 @@ function escapeMd(value) {
|
|
|
737
875
|
// break out of the table cell (code scanning alert #3).
|
|
738
876
|
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
739
877
|
}
|
|
878
|
+
// ─── Provenance conflicts ───────────────────────────────────────────────────
|
|
879
|
+
//
|
|
880
|
+
// Before publish refused cross-project appends (#123), two projects whose
|
|
881
|
+
// source_repo shared a trailing path segment derived the same slug, and the
|
|
882
|
+
// second publish landed as the next version of the first project's entry.
|
|
883
|
+
// Nothing rewrites those entries after the fact: the index reads only the
|
|
884
|
+
// newest version's metadata, so it advertises every version under whichever
|
|
885
|
+
// project published last, and a synthesis run reading the entry gets one
|
|
886
|
+
// project's spec history presented as another's (#148). Detection reads every
|
|
887
|
+
// version and reports the disagreement. Repair is deliberately manual —
|
|
888
|
+
// splitting an entry means inventing a slug, renumbering versions and
|
|
889
|
+
// repointing `latest`, all of which are paths docs/library-format.md calls
|
|
890
|
+
// ABI — so nothing here renames, renumbers, or moves anything.
|
|
891
|
+
/**
|
|
892
|
+
* Read-only check over the given entries: does every version of each entry
|
|
893
|
+
* record the same repository as its newest version? Comparison goes through
|
|
894
|
+
* `sameSourceRepo`, so spellings of one repository (scheme, `.git`, SCP
|
|
895
|
+
* syntax, casing where safe) do not count as disagreement. A version whose
|
|
896
|
+
* metadata is missing, unreadable, or lacks `source_repo` is skipped rather
|
|
897
|
+
* than reported — the stance the publish guard takes — and an entry whose
|
|
898
|
+
* newest version is unreadable is skipped entirely, since there is nothing to
|
|
899
|
+
* compare against. Never writes.
|
|
900
|
+
*
|
|
901
|
+
* A repository that genuinely moved and was re-published with
|
|
902
|
+
* `allowSourceRepoChange` leaves the same on-disk shape as a collision and is
|
|
903
|
+
* reported the same way; the history alone cannot tell the two apart.
|
|
904
|
+
*/
|
|
905
|
+
export async function detectProvenanceConflicts(libraryRoot, entries) {
|
|
906
|
+
const conflicts = [];
|
|
907
|
+
for (const entry of entries) {
|
|
908
|
+
const conflict = await findProvenanceConflict(libraryRoot, entry.namespace, entry.slug);
|
|
909
|
+
if (conflict)
|
|
910
|
+
conflicts.push(conflict);
|
|
911
|
+
}
|
|
912
|
+
return conflicts;
|
|
913
|
+
}
|
|
914
|
+
async function findProvenanceConflict(libraryRoot, namespace, slug) {
|
|
915
|
+
const versions = await listVersionDirs(entryRoot(libraryRoot, namespace, slug));
|
|
916
|
+
if (versions.length < 2)
|
|
917
|
+
return null;
|
|
918
|
+
const latest = versions[versions.length - 1];
|
|
919
|
+
const latestRepo = await readRecordedSourceRepo(libraryRoot, namespace, slug, latest);
|
|
920
|
+
if (latestRepo === null)
|
|
921
|
+
return null;
|
|
922
|
+
const disagreeing = [];
|
|
923
|
+
for (const version of versions.slice(0, -1)) {
|
|
924
|
+
const recorded = await readRecordedSourceRepo(libraryRoot, namespace, slug, version);
|
|
925
|
+
if (recorded === null)
|
|
926
|
+
continue;
|
|
927
|
+
if (!sameSourceRepo(recorded, latestRepo))
|
|
928
|
+
disagreeing.push({ version, source_repo: recorded });
|
|
929
|
+
}
|
|
930
|
+
if (disagreeing.length === 0)
|
|
931
|
+
return null;
|
|
932
|
+
const conflict = {
|
|
933
|
+
slug,
|
|
934
|
+
latest_version: latest,
|
|
935
|
+
source_repo: latestRepo,
|
|
936
|
+
disagreeing_versions: disagreeing,
|
|
937
|
+
};
|
|
938
|
+
if (namespace)
|
|
939
|
+
conflict.namespace = namespace;
|
|
940
|
+
return conflict;
|
|
941
|
+
}
|
|
740
942
|
// ─── Atomic YAML write ──────────────────────────────────────────────────────
|
|
741
943
|
async function atomicWriteYaml(path, value) {
|
|
742
944
|
const serialized = `${stringifySimpleYaml(value)}\n`;
|
|
@@ -748,6 +950,55 @@ async function atomicWriteYaml(path, value) {
|
|
|
748
950
|
function sha256(content) {
|
|
749
951
|
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
750
952
|
}
|
|
953
|
+
/**
|
|
954
|
+
* Resolve the repository reference a publish from `cwd` should record. The
|
|
955
|
+
* remote is preferred over the path because a path means nothing outside the
|
|
956
|
+
* machine that published it, and because a slug derived from the remote is
|
|
957
|
+
* stable across clones of one repository, which is what lets the collision
|
|
958
|
+
* guard compare something meaningful (#147).
|
|
959
|
+
*
|
|
960
|
+
* Resolution order: `origin`'s fetch URL, else the fetch URL of the remote
|
|
961
|
+
* the current branch tracks, else `cwd`. The remote is consulted only when
|
|
962
|
+
* `cwd` is the root of its work tree. A subdirectory keeps recording its
|
|
963
|
+
* path: every subdirectory of one repository would otherwise resolve to the
|
|
964
|
+
* same URL and the same slug, and the second one published would land as a
|
|
965
|
+
* new version of the first with no guard able to tell — exactly the
|
|
966
|
+
* cross-project append the guard exists to refuse.
|
|
967
|
+
*
|
|
968
|
+
* The URL is stored as git reports it. Spellings of one repository are
|
|
969
|
+
* reconciled at comparison time by `sameSourceRepo`, not here, so the
|
|
970
|
+
* recorded value stays human-readable. Never throws: a missing `git` binary
|
|
971
|
+
* or any git failure falls back to the path.
|
|
972
|
+
*/
|
|
973
|
+
export async function resolvePublishSourceRepo(cwd) {
|
|
974
|
+
const asPath = { source_repo: cwd, remote: null };
|
|
975
|
+
try {
|
|
976
|
+
const toplevel = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
977
|
+
if (!toplevel.ok || toplevel.stdout.trim() === "")
|
|
978
|
+
return asPath;
|
|
979
|
+
const [canonicalCwd, canonicalTop] = await Promise.all([canonicalPath(cwd), canonicalPath(toplevel.stdout.trim())]);
|
|
980
|
+
if (normalizeForComparison(canonicalCwd) !== normalizeForComparison(canonicalTop))
|
|
981
|
+
return asPath;
|
|
982
|
+
const origin = await runGit(cwd, ["remote", "get-url", "origin"]);
|
|
983
|
+
if (origin.ok && origin.stdout.trim() !== "")
|
|
984
|
+
return { source_repo: origin.stdout.trim(), remote: "origin" };
|
|
985
|
+
const branch = await runGit(cwd, ["symbolic-ref", "--short", "HEAD"]);
|
|
986
|
+
if (!branch.ok || branch.stdout.trim() === "")
|
|
987
|
+
return asPath;
|
|
988
|
+
const upstream = await runGit(cwd, ["config", "--get", `branch.${branch.stdout.trim()}.remote`]);
|
|
989
|
+
const remote = upstream.stdout.trim();
|
|
990
|
+
// `.` marks a branch tracking another local branch; there is no URL behind it.
|
|
991
|
+
if (!upstream.ok || remote === "" || remote === ".")
|
|
992
|
+
return asPath;
|
|
993
|
+
const url = await runGit(cwd, ["remote", "get-url", remote]);
|
|
994
|
+
if (url.ok && url.stdout.trim() !== "")
|
|
995
|
+
return { source_repo: url.stdout.trim(), remote };
|
|
996
|
+
return asPath;
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
return asPath;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
751
1002
|
/**
|
|
752
1003
|
* Optional convenience: stage and commit publish output. Never pushes.
|
|
753
1004
|
* On any failure, returns `{ ok: false, skipped: <reason> }` rather than
|
|
@@ -17,6 +17,12 @@ export interface LibraryConfig {
|
|
|
17
17
|
/** Whether `codecarto publish` should display a confirmation prompt
|
|
18
18
|
* with slug + source + library path before writing. Default true. */
|
|
19
19
|
publish_confirm: boolean;
|
|
20
|
+
/** True when some config layer set `publish_confirm`; false when the
|
|
21
|
+
* default above supplied it. Pi confirms either way (a dialog costs
|
|
22
|
+
* nothing), but the MCP server's refuse-unless-confirmed gate on
|
|
23
|
+
* `codecarto_publish` costs every host a round trip, so it applies only
|
|
24
|
+
* to hosts that actually configured the key. */
|
|
25
|
+
publish_confirm_configured: boolean;
|
|
20
26
|
}
|
|
21
27
|
export interface CodecartoConfig {
|
|
22
28
|
orchestrator: OrchestratorConfig;
|
|
@@ -38,6 +38,7 @@ const DEFAULT_CONFIG = {
|
|
|
38
38
|
path: null,
|
|
39
39
|
namespace: null,
|
|
40
40
|
publish_confirm: true,
|
|
41
|
+
publish_confirm_configured: false,
|
|
41
42
|
},
|
|
42
43
|
};
|
|
43
44
|
export async function loadCodecartoConfig(workspaceDir) {
|
|
@@ -101,6 +102,7 @@ function applyRaw(base, raw) {
|
|
|
101
102
|
}
|
|
102
103
|
if (typeof l.publish_confirm === "boolean") {
|
|
103
104
|
out.library.publish_confirm = l.publish_confirm;
|
|
105
|
+
out.library.publish_confirm_configured = true;
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
return out;
|