djournal 0.1.0 → 0.3.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/.agents/adapters/shared/journal-hook.js +94 -11
- package/.agents/rules/AUTOMATION.md +2 -2
- package/.agents/rules/FEAT.md +9 -9
- package/.agents/rules/METADATA.md +2 -1
- package/.agents/rules/STATE.md +18 -5
- package/.agents/skills/audit/SKILL.md +4 -2
- package/.agents/skills/init-work/SKILL.md +6 -5
- package/.agents/skills/journal/SKILL.md +3 -2
- package/.agents/skills/plan/SKILL.md +3 -2
- package/.agents/skills/recall/SKILL.md +5 -3
- package/.agents/skills/resume/SKILL.md +2 -1
- package/.agents/skills/switch/SKILL.md +6 -5
- package/.claude/settings.json +4 -2
- package/AGENTS.md +4 -2
- package/README.md +156 -59
- package/bin/journal.js +31 -2
- package/docs/architecture.md +175 -0
- package/docs/djournal-in-practice.md +246 -0
- package/docs/installation.md +152 -0
- package/docs/remote-sync.md +144 -0
- package/docs/uninstalling.md +78 -0
- package/docs/visibility-and-sharing.md +98 -0
- package/lib/installer/index.js +485 -13
- package/lib/installer/merge.js +74 -0
- package/package.json +3 -1
package/lib/installer/index.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const crypto = require("node:crypto");
|
|
4
|
+
const { spawnSync } = require("node:child_process");
|
|
4
5
|
const fs = require("node:fs");
|
|
6
|
+
const os = require("node:os");
|
|
5
7
|
const path = require("node:path");
|
|
6
8
|
const readline = require("node:readline/promises");
|
|
7
9
|
const {
|
|
8
10
|
equal,
|
|
9
11
|
hasManagedBlock,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
mergeConfigFragment,
|
|
13
|
+
removeConfigFragment,
|
|
12
14
|
removeManagedBlock,
|
|
13
15
|
upsertManagedBlock,
|
|
14
16
|
} = require("./merge.js");
|
|
@@ -18,6 +20,7 @@ const SUPPORTED_HARNESSES = ["codex", "claude-code"];
|
|
|
18
20
|
const FUTURE_HARNESSES = ["opencode", "pi", "zed"];
|
|
19
21
|
const MANIFEST_PATH = ".journal/.install/manifest.json";
|
|
20
22
|
const TRANSACTION_PATH = ".journal/.install/transaction.json";
|
|
23
|
+
const PROJECT_MARKER_PATH = ".djournal.json";
|
|
21
24
|
|
|
22
25
|
class InstallerError extends Error {
|
|
23
26
|
constructor(message, code = "INSTALL_ERROR") {
|
|
@@ -43,10 +46,163 @@ function parseJson(buffer, label) {
|
|
|
43
46
|
catch (error) { throw new InstallerError(`${label} is not valid JSON: ${error.message}`, "INVALID_JSON"); }
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
function parseFrontmatter(text) {
|
|
50
|
+
if (!text.startsWith("---\n")) return {};
|
|
51
|
+
const end = text.indexOf("\n---", 4);
|
|
52
|
+
if (end === -1) return {};
|
|
53
|
+
const result = {};
|
|
54
|
+
for (const line of text.slice(4, end).split("\n")) {
|
|
55
|
+
const match = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.*)$/);
|
|
56
|
+
if (!match) continue;
|
|
57
|
+
result[match[1]] = match[2].trim().replace(/^"(.*)"$/, "$1");
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stringifyFrontmatterValue(value) {
|
|
63
|
+
return /[:#{}\[\],"]|\s/.test(value) ? JSON.stringify(value) : value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function replaceFrontmatterField(text, field, value) {
|
|
67
|
+
if (!text.startsWith("---\n")) throw new InstallerError("work.md is missing frontmatter", "INVALID_JOURNAL");
|
|
68
|
+
const end = text.indexOf("\n---", 4);
|
|
69
|
+
if (end === -1) throw new InstallerError("work.md has unterminated frontmatter", "INVALID_JOURNAL");
|
|
70
|
+
const before = text.slice(0, end);
|
|
71
|
+
const after = text.slice(end);
|
|
72
|
+
const line = `${field}: ${stringifyFrontmatterValue(value)}`;
|
|
73
|
+
const pattern = new RegExp(`^${field}:.*$`, "m");
|
|
74
|
+
const next = pattern.test(before) ? before.replace(pattern, line) : `${before}\n${line}`;
|
|
75
|
+
return `${next}${after}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
46
78
|
function jsonBuffer(value) {
|
|
47
79
|
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
48
80
|
}
|
|
49
81
|
|
|
82
|
+
function readJsonFile(file, label) {
|
|
83
|
+
return parseJson(readOptional(file), label);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeJsonFile(file, value) {
|
|
87
|
+
atomicWrite(file, jsonBuffer(value));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function mergePlainObject(base, patch) {
|
|
91
|
+
const result = { ...base };
|
|
92
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
93
|
+
if (value && typeof value === "object" && !Array.isArray(value) && result[key] && typeof result[key] === "object" && !Array.isArray(result[key])) {
|
|
94
|
+
result[key] = mergePlainObject(result[key], value);
|
|
95
|
+
} else result[key] = value;
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function djournalHome(options = {}) {
|
|
101
|
+
return path.resolve(options.djournalHome || process.env.DJOURNAL_HOME || path.join(os.homedir(), ".djournal"));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function projectKeyFor(target) {
|
|
105
|
+
const base = path.basename(path.resolve(target)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "project";
|
|
106
|
+
return `${base}-${sha256(path.resolve(target)).slice(0, 8)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function projectStoreFor(target, options = {}) {
|
|
110
|
+
const projectKey = options.projectKey || projectKeyFor(target);
|
|
111
|
+
const root = path.join(djournalHome(options), "projects", projectKey);
|
|
112
|
+
return { projectKey, root, journalRoot: path.join(root, ".journal"), configFile: path.join(root, "config.json") };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resolveProjectStore(target, options = {}) {
|
|
116
|
+
return readProjectMarker(target) || projectStoreFor(target, options);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readProjectMarker(target) {
|
|
120
|
+
const marker = readOptional(resolveWithin(target, PROJECT_MARKER_PATH));
|
|
121
|
+
if (!marker) return null;
|
|
122
|
+
const value = parseJson(marker, PROJECT_MARKER_PATH);
|
|
123
|
+
if (value.schemaVersion !== SCHEMA_VERSION) {
|
|
124
|
+
throw new InstallerError(`unsupported project marker schema: ${value.schemaVersion}`, "UNSUPPORTED_MARKER");
|
|
125
|
+
}
|
|
126
|
+
if (!value.projectKey || !value.journalStore) {
|
|
127
|
+
throw new InstallerError("project marker is missing projectKey or journalStore", "INVALID_JSON");
|
|
128
|
+
}
|
|
129
|
+
const root = path.resolve(value.journalStore);
|
|
130
|
+
return { projectKey: value.projectKey, root, journalRoot: path.join(root, ".journal"), configFile: path.join(root, "config.json") };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function projectContext(target, options = {}) {
|
|
134
|
+
const marker = readProjectMarker(target);
|
|
135
|
+
if (marker) return { ...marker, target, global: true };
|
|
136
|
+
const store = projectStoreFor(target, options);
|
|
137
|
+
if (fs.existsSync(store.configFile) || fs.existsSync(store.journalRoot)) return { ...store, target, global: true };
|
|
138
|
+
return {
|
|
139
|
+
target,
|
|
140
|
+
global: false,
|
|
141
|
+
projectKey: null,
|
|
142
|
+
root: target,
|
|
143
|
+
journalRoot: resolveWithin(target, ".journal"),
|
|
144
|
+
configFile: resolveWithin(target, ".journal/config.json"),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function defaultProjectConfig(target, store) {
|
|
149
|
+
return {
|
|
150
|
+
schemaVersion: SCHEMA_VERSION,
|
|
151
|
+
project: {
|
|
152
|
+
id: store.projectKey,
|
|
153
|
+
name: path.basename(target),
|
|
154
|
+
sourcePath: path.resolve(target),
|
|
155
|
+
},
|
|
156
|
+
sync: {
|
|
157
|
+
enabled: false,
|
|
158
|
+
mode: "colocated",
|
|
159
|
+
path: path.resolve(target),
|
|
160
|
+
auto: false,
|
|
161
|
+
},
|
|
162
|
+
sharing: {
|
|
163
|
+
default: "local_only",
|
|
164
|
+
sharedWorkItems: {},
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function readProjectConfig(context) {
|
|
170
|
+
const config = readJsonFile(context.configFile, context.global ? "config.json" : ".journal/config.json");
|
|
171
|
+
if (!context.global) return config;
|
|
172
|
+
return mergePlainObject(defaultProjectConfig(context.target, context), config);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function writeProjectConfig(context, config) {
|
|
176
|
+
if (!context.global) writeJsonFile(context.configFile, config);
|
|
177
|
+
else writeJsonFile(context.configFile, config);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function copyDirectoryContents(source, destination, options = {}) {
|
|
181
|
+
if (!fs.existsSync(source)) return [];
|
|
182
|
+
if (fs.lstatSync(source).isSymbolicLink()) throw new InstallerError(`source is a symlink: ${source}`, "UNSAFE_PATH");
|
|
183
|
+
const copied = [];
|
|
184
|
+
for (const relative of listFiles(source)) {
|
|
185
|
+
if (options.exclude?.(relative)) continue;
|
|
186
|
+
const sourceFile = path.join(source, relative);
|
|
187
|
+
const destinationFile = path.join(destination, relative);
|
|
188
|
+
const destinationRoot = path.resolve(destination);
|
|
189
|
+
const resolved = path.resolve(destinationFile);
|
|
190
|
+
if (resolved !== destinationRoot && !resolved.startsWith(`${destinationRoot}${path.sep}`)) {
|
|
191
|
+
throw new InstallerError(`projection escapes target: ${relative}`, "UNSAFE_PATH");
|
|
192
|
+
}
|
|
193
|
+
if (fs.lstatSync(sourceFile).isSymbolicLink()) throw new InstallerError(`source file is a symlink: ${relative}`, "UNSAFE_PATH");
|
|
194
|
+
if (fs.existsSync(destinationFile) && fs.lstatSync(destinationFile).isSymbolicLink()) {
|
|
195
|
+
throw new InstallerError(`projection destination is a symlink: ${relative}`, "UNSAFE_PATH");
|
|
196
|
+
}
|
|
197
|
+
if (!options.dryRun) {
|
|
198
|
+
fs.mkdirSync(path.dirname(destinationFile), { recursive: true });
|
|
199
|
+
fs.copyFileSync(sourceFile, destinationFile);
|
|
200
|
+
}
|
|
201
|
+
copied.push(relative.split(path.sep).join("/"));
|
|
202
|
+
}
|
|
203
|
+
return copied;
|
|
204
|
+
}
|
|
205
|
+
|
|
50
206
|
function resolveWithin(target, relative) {
|
|
51
207
|
if (!relative || path.isAbsolute(relative)) {
|
|
52
208
|
throw new InstallerError(`unsafe destination path: ${relative}`, "UNSAFE_PATH");
|
|
@@ -82,7 +238,7 @@ function listFiles(directory, prefix = "") {
|
|
|
82
238
|
}
|
|
83
239
|
|
|
84
240
|
function sourceInventory(sourceRoot) {
|
|
85
|
-
const files = [
|
|
241
|
+
const files = [];
|
|
86
242
|
for (const directory of [".agents/rules", ".agents/skills", ".agents/adapters"]) {
|
|
87
243
|
for (const relative of listFiles(path.join(sourceRoot, directory))) {
|
|
88
244
|
files.push(`${directory}/${relative}`);
|
|
@@ -252,21 +408,36 @@ function markdownRecord(target, relative, body, oldRecord, harness) {
|
|
|
252
408
|
};
|
|
253
409
|
}
|
|
254
410
|
|
|
411
|
+
function journalAccessFragment(harness, store) {
|
|
412
|
+
if (harness !== "claude-code") return {};
|
|
413
|
+
const commands = ["status", "doctor", "config", "share", "sync"];
|
|
414
|
+
return {
|
|
415
|
+
permissions: {
|
|
416
|
+
additionalDirectories: [store.root],
|
|
417
|
+
allow: commands.flatMap((command) => [
|
|
418
|
+
`Bash(journal ${command}:*)`,
|
|
419
|
+
`Bash(djournal ${command}:*)`,
|
|
420
|
+
]),
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
255
425
|
function jsonRecord(target, relative, fragment, oldRecord, harness) {
|
|
256
426
|
const absolute = resolveWithin(target, relative);
|
|
257
427
|
const original = readOptional(absolute);
|
|
258
428
|
const current = parseJson(original, relative);
|
|
259
429
|
let base = current;
|
|
260
|
-
if (oldRecord?.injected) {
|
|
261
|
-
const removed =
|
|
430
|
+
if (oldRecord?.injected || oldRecord?.injectedPermissions) {
|
|
431
|
+
const removed = removeConfigFragment(base, oldRecord.injected, oldRecord.injectedPermissions);
|
|
262
432
|
if (removed.conflicts.length) {
|
|
263
|
-
throw new InstallerError(`managed
|
|
433
|
+
throw new InstallerError(`managed JSON fragment was modified: ${relative}`, "ASSET_CONFLICT");
|
|
264
434
|
}
|
|
265
435
|
base = removed.value;
|
|
266
436
|
}
|
|
267
|
-
const merged =
|
|
437
|
+
const merged = mergeConfigFragment(base, fragment);
|
|
268
438
|
const injectedCount = Object.values(merged.injected).reduce((total, groups) => total + groups.length, 0);
|
|
269
|
-
const
|
|
439
|
+
const injectedPermissionCount = Object.values(merged.injectedPermissions).reduce((total, values) => total + values.length, 0);
|
|
440
|
+
const next = injectedCount === 0 && injectedPermissionCount === 0 && original !== null ? original : jsonBuffer(merged.value);
|
|
270
441
|
return {
|
|
271
442
|
op: operation(target, relative, next),
|
|
272
443
|
record: {
|
|
@@ -276,6 +447,7 @@ function jsonRecord(target, relative, fragment, oldRecord, harness) {
|
|
|
276
447
|
created: oldRecord?.created ?? original === null,
|
|
277
448
|
installedHash: sha256(next),
|
|
278
449
|
injected: merged.injected,
|
|
450
|
+
injectedPermissions: merged.injectedPermissions,
|
|
279
451
|
},
|
|
280
452
|
};
|
|
281
453
|
}
|
|
@@ -307,7 +479,7 @@ function harnessFragment(sourceRoot, harness) {
|
|
|
307
479
|
return parseJson(fs.readFileSync(path.join(sourceRoot, relative)), relative);
|
|
308
480
|
}
|
|
309
481
|
|
|
310
|
-
function planInstall(sourceRoot, target, harnesses, oldManifest) {
|
|
482
|
+
function planInstall(sourceRoot, target, harnesses, oldManifest, store) {
|
|
311
483
|
const old = recordMap(oldManifest);
|
|
312
484
|
const records = [];
|
|
313
485
|
const operations = [];
|
|
@@ -326,7 +498,13 @@ function planInstall(sourceRoot, target, harnesses, oldManifest) {
|
|
|
326
498
|
if (harnesses.includes("claude-code")) {
|
|
327
499
|
const bridge = markdownRecord(target, "CLAUDE.md", "@AGENTS.md", old.get("CLAUDE.md"), "claude-code");
|
|
328
500
|
records.push(bridge.record); operations.push(bridge.op);
|
|
329
|
-
const hooks = jsonRecord(
|
|
501
|
+
const hooks = jsonRecord(
|
|
502
|
+
target,
|
|
503
|
+
".claude/settings.json",
|
|
504
|
+
mergePlainObject(harnessFragment(sourceRoot, "claude-code"), journalAccessFragment("claude-code", store)),
|
|
505
|
+
old.get(".claude/settings.json"),
|
|
506
|
+
"claude-code",
|
|
507
|
+
);
|
|
330
508
|
records.push(hooks.record); operations.push(hooks.op);
|
|
331
509
|
}
|
|
332
510
|
|
|
@@ -356,6 +534,285 @@ function checkTarget(target) {
|
|
|
356
534
|
return resolved;
|
|
357
535
|
}
|
|
358
536
|
|
|
537
|
+
function readActiveWork(context, requested) {
|
|
538
|
+
const journalRoot = context.journalRoot;
|
|
539
|
+
let slug = requested;
|
|
540
|
+
if (!slug) {
|
|
541
|
+
const state = parseJson(readOptional(path.join(journalRoot, "state.json")), ".journal/state.json");
|
|
542
|
+
slug = state.active_work_name;
|
|
543
|
+
}
|
|
544
|
+
if (typeof slug !== "string" || !slug) {
|
|
545
|
+
throw new InstallerError("No active work found. Run init-work or switch first.", "NO_ACTIVE_WORK");
|
|
546
|
+
}
|
|
547
|
+
if (slug.includes("/") || slug.includes("\\") || slug === "." || slug === "..") {
|
|
548
|
+
throw new InstallerError(`invalid work slug: ${slug}`, "INVALID_WORK");
|
|
549
|
+
}
|
|
550
|
+
const relative = `.journal/work/${slug}/work.md`;
|
|
551
|
+
const file = path.join(journalRoot, "work", slug, "work.md");
|
|
552
|
+
const buffer = readOptional(file);
|
|
553
|
+
if (!buffer) throw new InstallerError(`work item not found: ${slug}`, "INVALID_WORK");
|
|
554
|
+
const text = buffer.toString("utf8");
|
|
555
|
+
const metadata = parseFrontmatter(text);
|
|
556
|
+
return { slug, relative, file, text, metadata, visibility: metadata.visibility || "local_only" };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function readAllWork(context) {
|
|
560
|
+
const workRoot = path.join(context.journalRoot, "work");
|
|
561
|
+
const entries = fs.existsSync(workRoot) ? fs.readdirSync(workRoot, { withFileTypes: true }) : [];
|
|
562
|
+
const workItems = entries
|
|
563
|
+
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
|
|
564
|
+
.map((entry) => entry.name)
|
|
565
|
+
.filter((slug) => fs.existsSync(path.join(workRoot, slug, "work.md")))
|
|
566
|
+
.sort()
|
|
567
|
+
.map((slug) => readActiveWork(context, slug));
|
|
568
|
+
if (!workItems.length) throw new InstallerError("no journal work items found", "NO_ACTIVE_WORK");
|
|
569
|
+
return workItems;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function readJournalConfig(target) {
|
|
573
|
+
const context = projectContext(target);
|
|
574
|
+
return readProjectConfig(context);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function syncConfig(target) {
|
|
578
|
+
const config = readJournalConfig(target);
|
|
579
|
+
return config.sync && typeof config.sync === "object" ? config.sync : {};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function sharingConfig(config) {
|
|
583
|
+
return config.sharing && typeof config.sharing === "object" ? config.sharing : {};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function sharedWorkItems(config) {
|
|
587
|
+
const sharing = sharingConfig(config);
|
|
588
|
+
return sharing.sharedWorkItems && typeof sharing.sharedWorkItems === "object" ? sharing.sharedWorkItems : {};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function isWorkShared(config, slug) {
|
|
592
|
+
return Object.prototype.hasOwnProperty.call(sharedWorkItems(config), slug);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function parseConfigValue(value) {
|
|
596
|
+
if (value === "true") return true;
|
|
597
|
+
if (value === "false") return false;
|
|
598
|
+
if (value === "null") return null;
|
|
599
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
|
|
600
|
+
return value;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function setNested(value, keyPath, next) {
|
|
604
|
+
const parts = keyPath.split(".").filter(Boolean);
|
|
605
|
+
if (!parts.length) throw new InstallerError("config key is required", "USAGE");
|
|
606
|
+
const result = { ...value };
|
|
607
|
+
let cursor = result;
|
|
608
|
+
for (const part of parts.slice(0, -1)) {
|
|
609
|
+
const current = cursor[part];
|
|
610
|
+
cursor[part] = current && typeof current === "object" && !Array.isArray(current) ? { ...current } : {};
|
|
611
|
+
cursor = cursor[part];
|
|
612
|
+
}
|
|
613
|
+
cursor[parts.at(-1)] = next;
|
|
614
|
+
return result;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function getNested(value, keyPath) {
|
|
618
|
+
if (!keyPath) return value;
|
|
619
|
+
let cursor = value;
|
|
620
|
+
for (const part of keyPath.split(".").filter(Boolean)) {
|
|
621
|
+
if (!cursor || typeof cursor !== "object" || !(part in cursor)) return undefined;
|
|
622
|
+
cursor = cursor[part];
|
|
623
|
+
}
|
|
624
|
+
return cursor;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function configure(options) {
|
|
628
|
+
const target = checkTarget(options.target || process.cwd());
|
|
629
|
+
const context = projectContext(target, options);
|
|
630
|
+
if (!context.global) throw new InstallerError("djournal project store is not initialized; run journal install first", "NOT_INSTALLED");
|
|
631
|
+
const current = readProjectConfig(context);
|
|
632
|
+
if (!options.key) return { action: "config", target, projectKey: context.projectKey, config: current };
|
|
633
|
+
if (typeof options.value === "undefined") {
|
|
634
|
+
return { action: "config", target, projectKey: context.projectKey, key: options.key, value: getNested(current, options.key) };
|
|
635
|
+
}
|
|
636
|
+
const next = setNested(current, options.key, parseConfigValue(options.value));
|
|
637
|
+
if (!options.dryRun) writeProjectConfig(context, next);
|
|
638
|
+
return { action: "config", target, projectKey: context.projectKey, key: options.key, value: getNested(next, options.key), changed: true, dryRun: !!options.dryRun };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function gitIgnored(target, relative, options = {}) {
|
|
642
|
+
const result = (options.runner || spawnSync)("git", ["-C", target, "check-ignore", "-q", relative], {
|
|
643
|
+
encoding: "utf8",
|
|
644
|
+
timeout: options.timeout || 30000,
|
|
645
|
+
});
|
|
646
|
+
if (result.error) return false;
|
|
647
|
+
return result.status === 0;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function share(options) {
|
|
651
|
+
const target = checkTarget(options.target || process.cwd());
|
|
652
|
+
const context = projectContext(target, options);
|
|
653
|
+
const config = readProjectConfig(context);
|
|
654
|
+
const projectionRoot = projectionRootFor(target, config);
|
|
655
|
+
const shared = sharedWorkItems(config);
|
|
656
|
+
const workItems = options.all ? readAllWork(context) : [readActiveWork(context, options.work)];
|
|
657
|
+
const sharedAt = new Date().toISOString();
|
|
658
|
+
const sharedBy = process.env.JOURNAL_USER_ID || runGitConfigEmail(options) || `${os.userInfo().username}@local`;
|
|
659
|
+
const nextShared = { ...shared };
|
|
660
|
+
const results = workItems.map((work) => {
|
|
661
|
+
const ignored = config.sync?.mode === "colocated" ? gitIgnored(projectionRoot, `.journal/work/${work.slug}`, options) : false;
|
|
662
|
+
const warning = ignored
|
|
663
|
+
? `.journal/work/${work.slug} appears to be ignored by Git; update .gitignore or force-add it before expecting colocated Git commits to share this work.`
|
|
664
|
+
: undefined;
|
|
665
|
+
const changed = !Object.prototype.hasOwnProperty.call(shared, work.slug);
|
|
666
|
+
if (changed) nextShared[work.slug] = { sharedAt, sharedBy };
|
|
667
|
+
return { work: work.slug, changed, shared: true, gitIgnored: ignored, warning };
|
|
668
|
+
});
|
|
669
|
+
const changed = results.some((result) => result.changed);
|
|
670
|
+
if (!options.all) {
|
|
671
|
+
const result = results[0];
|
|
672
|
+
if (!changed) return { action: "share", target, projectKey: context.projectKey, ...result };
|
|
673
|
+
}
|
|
674
|
+
const next = mergePlainObject(config, {
|
|
675
|
+
sharing: {
|
|
676
|
+
sharedWorkItems: nextShared,
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
if (changed && !options.dryRun) writeProjectConfig(context, next);
|
|
680
|
+
if (!options.all) return { action: "share", target, projectKey: context.projectKey, ...results[0], dryRun: !!options.dryRun };
|
|
681
|
+
return {
|
|
682
|
+
action: "share",
|
|
683
|
+
target,
|
|
684
|
+
projectKey: context.projectKey,
|
|
685
|
+
all: true,
|
|
686
|
+
changed,
|
|
687
|
+
shared: true,
|
|
688
|
+
workItems: results,
|
|
689
|
+
dryRun: !!options.dryRun,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function runGitConfigEmail(options = {}) {
|
|
694
|
+
const result = (options.runner || spawnSync)("git", ["config", "user.email"], {
|
|
695
|
+
encoding: "utf8",
|
|
696
|
+
timeout: options.timeout || 30000,
|
|
697
|
+
});
|
|
698
|
+
if (result.error || result.status !== 0) return "";
|
|
699
|
+
return (result.stdout || "").trim();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function runGit(gitRoot, args, options = {}) {
|
|
703
|
+
const result = (options.runner || spawnSync)("git", ["-C", gitRoot, ...args], {
|
|
704
|
+
encoding: "utf8",
|
|
705
|
+
timeout: options.timeout || 30000,
|
|
706
|
+
});
|
|
707
|
+
if (result.error) throw new InstallerError(result.error.message, "GIT_ERROR");
|
|
708
|
+
if (result.status !== 0) {
|
|
709
|
+
const detail = `${result.stderr || ""}${result.stdout || ""}`.trim();
|
|
710
|
+
throw new InstallerError(detail || `git ${args.join(" ")} failed`, "GIT_ERROR");
|
|
711
|
+
}
|
|
712
|
+
return (result.stdout || "").trim();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function gitRootFor(directory, options = {}) {
|
|
716
|
+
const result = (options.runner || spawnSync)("git", ["-C", directory, "rev-parse", "--show-toplevel"], {
|
|
717
|
+
encoding: "utf8",
|
|
718
|
+
timeout: options.timeout || 30000,
|
|
719
|
+
});
|
|
720
|
+
if (result.error) throw new InstallerError(result.error.message, "GIT_ERROR");
|
|
721
|
+
if (result.status !== 0) throw new InstallerError(".journal is not inside a Git work tree", "GIT_ERROR");
|
|
722
|
+
return (result.stdout || "").trim();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function journalPathspec(gitRoot, journalRoot) {
|
|
726
|
+
const relative = path.relative(gitRoot, journalRoot).split(path.sep).join("/");
|
|
727
|
+
return relative && !relative.startsWith("..") ? relative : ".";
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function projectionRootFor(target, config) {
|
|
731
|
+
const sync = config.sync && typeof config.sync === "object" ? config.sync : {};
|
|
732
|
+
if (sync.path) return path.resolve(target, sync.path);
|
|
733
|
+
return path.resolve(target);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function projectJournalRoot(projectionRoot) {
|
|
737
|
+
return path.join(projectionRoot, ".journal");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function projectSharedWork(context, config, work, options = {}) {
|
|
741
|
+
const targetRoot = projectionRootFor(context.target, config);
|
|
742
|
+
const destinationJournal = projectJournalRoot(targetRoot);
|
|
743
|
+
const sourceWork = path.join(context.journalRoot, "work", work.slug);
|
|
744
|
+
const destinationWork = path.join(destinationJournal, "work", work.slug);
|
|
745
|
+
if (!fs.existsSync(sourceWork)) throw new InstallerError(`work item not found: ${work.slug}`, "INVALID_WORK");
|
|
746
|
+
const files = copyDirectoryContents(sourceWork, destinationWork, options).map((file) => `.journal/work/${work.slug}/${file}`);
|
|
747
|
+
return { projectionRoot: targetRoot, journalRoot: destinationJournal, files };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function bootstrapProjectStore(target, options = {}) {
|
|
751
|
+
const store = options.store || resolveProjectStore(target, options);
|
|
752
|
+
const existingConfig = readOptional(store.configFile);
|
|
753
|
+
const config = existingConfig
|
|
754
|
+
? mergePlainObject(defaultProjectConfig(target, store), parseJson(existingConfig, "config.json"))
|
|
755
|
+
: defaultProjectConfig(target, store);
|
|
756
|
+
const legacyConfig = readOptional(resolveWithin(target, ".journal/config.json"));
|
|
757
|
+
if (legacyConfig) {
|
|
758
|
+
const parsedLegacy = parseJson(legacyConfig, ".journal/config.json");
|
|
759
|
+
if (parsedLegacy.sync || parsedLegacy.sharing) {
|
|
760
|
+
config.sync = { ...config.sync, ...(parsedLegacy.sync || {}) };
|
|
761
|
+
config.sharing = mergePlainObject(config.sharing, parsedLegacy.sharing || {});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (!options.dryRun) {
|
|
765
|
+
fs.mkdirSync(store.journalRoot, { recursive: true });
|
|
766
|
+
copyDirectoryContents(resolveWithin(target, ".journal"), store.journalRoot, {
|
|
767
|
+
dryRun: false,
|
|
768
|
+
exclude: (relative) => relative === "config.json" || relative.startsWith(".install/"),
|
|
769
|
+
});
|
|
770
|
+
writeProjectConfig({ ...store, target, global: true }, config);
|
|
771
|
+
}
|
|
772
|
+
return { ...store, config };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function sync(options) {
|
|
776
|
+
const target = checkTarget(options.target || process.cwd());
|
|
777
|
+
const context = projectContext(target, options);
|
|
778
|
+
const config = readProjectConfig(context);
|
|
779
|
+
const syncOptions = config.sync && typeof config.sync === "object" ? config.sync : {};
|
|
780
|
+
if (syncOptions.enabled !== true) {
|
|
781
|
+
return { action: "sync", target, skipped: true, reason: "sync is not enabled" };
|
|
782
|
+
}
|
|
783
|
+
if (!["colocated", "standalone"].includes(syncOptions.mode)) {
|
|
784
|
+
return { action: "sync", target, skipped: true, reason: "sync mode is not standalone" };
|
|
785
|
+
}
|
|
786
|
+
const work = readActiveWork(context, options.work);
|
|
787
|
+
if (!isWorkShared(config, work.slug)) {
|
|
788
|
+
return { action: "sync", target, work: work.slug, skipped: true, reason: "work is not shared" };
|
|
789
|
+
}
|
|
790
|
+
const projection = projectSharedWork(context, config, work, options);
|
|
791
|
+
if (syncOptions.mode === "colocated") {
|
|
792
|
+
return { action: "sync", target, work: work.slug, projectionRoot: projection.projectionRoot, files: projection.files, dryRun: !!options.dryRun };
|
|
793
|
+
}
|
|
794
|
+
const gitRoot = gitRootFor(projection.journalRoot, options);
|
|
795
|
+
const pathspec = journalPathspec(gitRoot, projection.journalRoot);
|
|
796
|
+
const unmerged = runGit(gitRoot, ["diff", "--name-only", "--diff-filter=U", "--", pathspec], options);
|
|
797
|
+
if (unmerged) throw new InstallerError(`unresolved journal conflicts:\n${unmerged}`, "SYNC_CONFLICT");
|
|
798
|
+
if (options.dryRun) return { action: "sync", target, work: work.slug, projectionRoot: projection.projectionRoot, gitRoot, pathspec, files: projection.files, dryRun: true };
|
|
799
|
+
|
|
800
|
+
runGit(gitRoot, ["pull", "--ff-only"], options);
|
|
801
|
+
const afterPullUnmerged = runGit(gitRoot, ["diff", "--name-only", "--diff-filter=U", "--", pathspec], options);
|
|
802
|
+
if (afterPullUnmerged) throw new InstallerError(`unresolved journal conflicts:\n${afterPullUnmerged}`, "SYNC_CONFLICT");
|
|
803
|
+
runGit(gitRoot, ["add", "--", pathspec], options);
|
|
804
|
+
let committed = true;
|
|
805
|
+
try {
|
|
806
|
+
runGit(gitRoot, ["diff", "--cached", "--quiet", "--", pathspec], options);
|
|
807
|
+
committed = false;
|
|
808
|
+
} catch (error) {
|
|
809
|
+
if (!(error instanceof InstallerError) || error.code !== "GIT_ERROR") throw error;
|
|
810
|
+
runGit(gitRoot, ["commit", "-m", `chore(journal): sync ${work.slug}`], options);
|
|
811
|
+
}
|
|
812
|
+
runGit(gitRoot, ["push"], options);
|
|
813
|
+
return { action: "sync", target, work: work.slug, projectionRoot: projection.projectionRoot, gitRoot, pathspec, files: projection.files, committed, pushed: true, auto: !!options.auto };
|
|
814
|
+
}
|
|
815
|
+
|
|
359
816
|
function doctor(options) {
|
|
360
817
|
const target = checkTarget(options.target || process.cwd());
|
|
361
818
|
const detected = detectHarnesses(target, options);
|
|
@@ -389,7 +846,8 @@ async function install(options) {
|
|
|
389
846
|
const detected = health.detected;
|
|
390
847
|
const selected = await selectHarnesses({ ...options, interactive: options.interactive ?? process.stdin.isTTY }, detected);
|
|
391
848
|
const harnesses = [...new Set([...(oldManifest?.harnesses || []), ...selected])].sort();
|
|
392
|
-
const
|
|
849
|
+
const store = bootstrapProjectStore(target, { ...options, store: resolveProjectStore(target, options) });
|
|
850
|
+
const planned = planInstall(sourceRoot, target, harnesses, oldManifest, store);
|
|
393
851
|
const manifest = {
|
|
394
852
|
schemaVersion: SCHEMA_VERSION,
|
|
395
853
|
toolVersion: packageVersion(sourceRoot),
|
|
@@ -398,6 +856,11 @@ async function install(options) {
|
|
|
398
856
|
harnesses,
|
|
399
857
|
files: planned.records.sort((a, b) => a.path.localeCompare(b.path)),
|
|
400
858
|
};
|
|
859
|
+
planned.operations.push(operation(target, PROJECT_MARKER_PATH, jsonBuffer({
|
|
860
|
+
schemaVersion: SCHEMA_VERSION,
|
|
861
|
+
projectKey: store.projectKey,
|
|
862
|
+
journalStore: store.root,
|
|
863
|
+
})));
|
|
401
864
|
planned.operations.push(operation(target, MANIFEST_PATH, jsonBuffer(manifest)));
|
|
402
865
|
applyTransaction(target, planned.operations, options);
|
|
403
866
|
if (!options.dryRun) {
|
|
@@ -440,7 +903,7 @@ function uninstallRecord(target, record, conflicts) {
|
|
|
440
903
|
const injectedCount = Object.values(record.injected || {}).reduce((total, groups) => total + groups.length, 0);
|
|
441
904
|
if (injectedCount === 0 && !record.created) return null;
|
|
442
905
|
const parsed = parseJson(current, record.path);
|
|
443
|
-
const removed =
|
|
906
|
+
const removed = removeConfigFragment(parsed, record.injected, record.injectedPermissions);
|
|
444
907
|
if (removed.conflicts.length) { conflicts.push(record.path); return null; }
|
|
445
908
|
const empty = Object.keys(removed.value).length === 0;
|
|
446
909
|
return operation(target, record.path, empty && record.created ? null : jsonBuffer(removed.value));
|
|
@@ -504,8 +967,11 @@ function recordStatus(target, record) {
|
|
|
504
967
|
if (record.mode === "json_merge") {
|
|
505
968
|
try {
|
|
506
969
|
const value = parseJson(current, record.path);
|
|
507
|
-
const
|
|
970
|
+
const hooksPresent = Object.entries(record.injected || {}).every(([event, groups]) =>
|
|
508
971
|
groups.every((group) => value.hooks?.[event]?.some((candidate) => equal(candidate, group))));
|
|
972
|
+
const permissionsPresent = Object.entries(record.injectedPermissions || {}).every(([key, values]) =>
|
|
973
|
+
values.every((item) => value.permissions?.[key]?.some((candidate) => equal(candidate, item))));
|
|
974
|
+
const present = hooksPresent && permissionsPresent;
|
|
509
975
|
return present ? (sha256(current) === record.installedHash ? "clean" : "modified") : "missing";
|
|
510
976
|
} catch { return "invalid"; }
|
|
511
977
|
}
|
|
@@ -523,15 +989,21 @@ function status(options) {
|
|
|
523
989
|
module.exports = {
|
|
524
990
|
InstallerError,
|
|
525
991
|
MANIFEST_PATH,
|
|
992
|
+
PROJECT_MARKER_PATH,
|
|
526
993
|
SUPPORTED_HARNESSES,
|
|
527
994
|
applyTransaction,
|
|
995
|
+
configure,
|
|
528
996
|
detectHarnesses,
|
|
529
997
|
doctor,
|
|
998
|
+
djournalHome,
|
|
530
999
|
install,
|
|
531
1000
|
loadManifest,
|
|
1001
|
+
projectContext,
|
|
532
1002
|
recoverTransaction,
|
|
533
1003
|
selectHarnesses,
|
|
1004
|
+
share,
|
|
534
1005
|
sourceInventory,
|
|
1006
|
+
sync,
|
|
535
1007
|
status,
|
|
536
1008
|
uninstall,
|
|
537
1009
|
upgrade,
|