@mohammadhprp/system-prompt 0.13.0 → 0.13.2
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/bin/system-prompt.js +1 -1
- package/framework/skills/README.md +1 -0
- package/framework/skills/grilling/SKILL.md +34 -0
- package/framework/skills/grilling/examples.md +47 -0
- package/package.json +1 -1
- package/src/catalog.js +13 -0
- package/src/cli/args.js +51 -0
- package/src/cli/clack-ui.js +13 -0
- package/src/cli/index.js +47 -0
- package/src/cli/interactive.js +213 -0
- package/src/cli/plan.js +85 -0
- package/src/cli/tui-preferences.js +118 -0
- package/src/config/mcp.js +25 -0
- package/src/config/opencode.js +47 -0
- package/src/config/tui.js +92 -0
- package/src/doctor.js +23 -24
- package/src/hash.js +5 -0
- package/src/install/env.js +69 -0
- package/src/install/files.js +151 -0
- package/src/install/index.js +124 -0
- package/src/install/lock.js +159 -0
- package/src/install/merge.js +62 -0
- package/src/install/templates.js +76 -0
- package/src/item-layout.js +31 -0
- package/src/paths.js +59 -0
- package/src/ui.js +32 -0
- package/src/agent-configs.js +0 -100
- package/src/cli.js +0 -334
- package/src/installer.js +0 -672
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { normalizeTuiPreferences, TUI_THEMES, tuiPreferencesFromConfig } from '../config/tui.js';
|
|
5
|
+
|
|
6
|
+
export async function readTuiPreferences(absTarget) {
|
|
7
|
+
try {
|
|
8
|
+
return tuiPreferencesFromConfig(JSON.parse(await readFile(resolve(absTarget, 'tui.json'), 'utf-8')));
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) return {};
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function collectTuiPreferences(existing, ui) {
|
|
16
|
+
const base = normalizeTuiPreferences(existing);
|
|
17
|
+
|
|
18
|
+
const theme = await ui.select({
|
|
19
|
+
message: 'TUI theme',
|
|
20
|
+
options: TUI_THEMES.map(value => ({
|
|
21
|
+
value,
|
|
22
|
+
label: value,
|
|
23
|
+
hint: value === 'system' ? 'Adapts to your terminal' : undefined,
|
|
24
|
+
})),
|
|
25
|
+
initialValue: base.theme,
|
|
26
|
+
});
|
|
27
|
+
if (ui.isCancel(theme)) return null;
|
|
28
|
+
|
|
29
|
+
const diffStyle = await ui.select({
|
|
30
|
+
message: 'Diff style',
|
|
31
|
+
options: [
|
|
32
|
+
{ value: 'auto', label: 'auto', hint: 'Adapts to terminal width' },
|
|
33
|
+
{ value: 'stacked', label: 'stacked', hint: 'Always single column' },
|
|
34
|
+
],
|
|
35
|
+
initialValue: base.diff_style,
|
|
36
|
+
});
|
|
37
|
+
if (ui.isCancel(diffStyle)) return null;
|
|
38
|
+
|
|
39
|
+
const cursorStyle = await ui.select({
|
|
40
|
+
message: 'Cursor style',
|
|
41
|
+
options: ['block', 'underline', 'line', 'default'].map(value => ({ value, label: value })),
|
|
42
|
+
initialValue: base.cursor.style,
|
|
43
|
+
});
|
|
44
|
+
if (ui.isCancel(cursorStyle)) return null;
|
|
45
|
+
|
|
46
|
+
const scrollSpeed = await ui.select({
|
|
47
|
+
message: 'Scroll speed',
|
|
48
|
+
options: [
|
|
49
|
+
{ value: 1, label: '1', hint: 'Slow' },
|
|
50
|
+
{ value: 2, label: '2' },
|
|
51
|
+
{ value: 3, label: '3', hint: 'Default' },
|
|
52
|
+
{ value: 4, label: '4' },
|
|
53
|
+
{ value: 5, label: '5', hint: 'Fast' },
|
|
54
|
+
],
|
|
55
|
+
initialValue: base.scroll_speed,
|
|
56
|
+
});
|
|
57
|
+
if (ui.isCancel(scrollSpeed)) return null;
|
|
58
|
+
|
|
59
|
+
const scrollAcceleration = await ui.confirm({
|
|
60
|
+
message: 'Enable scroll acceleration?',
|
|
61
|
+
initialValue: base.scroll_acceleration,
|
|
62
|
+
});
|
|
63
|
+
if (ui.isCancel(scrollAcceleration)) return null;
|
|
64
|
+
|
|
65
|
+
const mouse = await ui.confirm({
|
|
66
|
+
message: 'Enable mouse support?',
|
|
67
|
+
initialValue: base.mouse,
|
|
68
|
+
});
|
|
69
|
+
if (ui.isCancel(mouse)) return null;
|
|
70
|
+
|
|
71
|
+
const attentionEnabled = await ui.confirm({
|
|
72
|
+
message: 'Enable attention notifications and sounds?',
|
|
73
|
+
initialValue: base.attention.enabled,
|
|
74
|
+
});
|
|
75
|
+
if (ui.isCancel(attentionEnabled)) return null;
|
|
76
|
+
|
|
77
|
+
const attention = { ...base.attention, enabled: attentionEnabled };
|
|
78
|
+
if (attentionEnabled) {
|
|
79
|
+
const notifications = await ui.confirm({
|
|
80
|
+
message: 'Desktop notifications?',
|
|
81
|
+
initialValue: base.attention.notifications,
|
|
82
|
+
});
|
|
83
|
+
if (ui.isCancel(notifications)) return null;
|
|
84
|
+
|
|
85
|
+
const sound = await ui.confirm({
|
|
86
|
+
message: 'Sound alerts?',
|
|
87
|
+
initialValue: base.attention.sound,
|
|
88
|
+
});
|
|
89
|
+
if (ui.isCancel(sound)) return null;
|
|
90
|
+
|
|
91
|
+
const volume = await ui.select({
|
|
92
|
+
message: 'Alert volume',
|
|
93
|
+
options: [
|
|
94
|
+
{ value: 0.2, label: '20%' },
|
|
95
|
+
{ value: 0.4, label: '40%', hint: 'Default' },
|
|
96
|
+
{ value: 0.6, label: '60%' },
|
|
97
|
+
{ value: 0.8, label: '80%' },
|
|
98
|
+
{ value: 1, label: '100%' },
|
|
99
|
+
],
|
|
100
|
+
initialValue: base.attention.volume,
|
|
101
|
+
});
|
|
102
|
+
if (ui.isCancel(volume)) return null;
|
|
103
|
+
|
|
104
|
+
attention.notifications = notifications;
|
|
105
|
+
attention.sound = sound;
|
|
106
|
+
attention.volume = volume;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return normalizeTuiPreferences({
|
|
110
|
+
theme,
|
|
111
|
+
diff_style: diffStyle,
|
|
112
|
+
cursor: { style: cursorStyle, blinking: base.cursor.blinking },
|
|
113
|
+
scroll_speed: scrollSpeed,
|
|
114
|
+
scroll_acceleration: scrollAcceleration,
|
|
115
|
+
mouse,
|
|
116
|
+
attention,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
import { isMissing, resolveSource } from '../paths.js';
|
|
4
|
+
import { status } from '../ui.js';
|
|
5
|
+
|
|
6
|
+
export async function loadMcpConfigs(selectedIds) {
|
|
7
|
+
const entries = {};
|
|
8
|
+
for (const id of selectedIds) {
|
|
9
|
+
try {
|
|
10
|
+
const configPath = resolveSource(`framework/mcps/${id}/configs/opencode.json`);
|
|
11
|
+
const raw = await readFile(configPath, 'utf-8');
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
if (parsed.mcp) {
|
|
14
|
+
Object.assign(entries, parsed.mcp);
|
|
15
|
+
}
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (isMissing(error)) {
|
|
18
|
+
console.warn(status('warn', `No opencode.json config found for MCP: ${id}`));
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return entries;
|
|
25
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function generateOpenCodeConfig({ selections, mcpEntries, includeAgentsMd }) {
|
|
2
|
+
const plugins = [];
|
|
3
|
+
if (selections.plugins?.includes('opencode-goal-plugin')) {
|
|
4
|
+
plugins.push('@prevalentware/opencode-goal-plugin');
|
|
5
|
+
}
|
|
6
|
+
if (selections.plugins?.includes('ponytail')) {
|
|
7
|
+
plugins.push('@dietrichgebert/ponytail');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const instructions = [];
|
|
11
|
+
if (includeAgentsMd) instructions.push('AGENTS.md');
|
|
12
|
+
if (selections.memory?.length) instructions.push('.opencode/memory/*.md');
|
|
13
|
+
|
|
14
|
+
const config = {
|
|
15
|
+
$schema: 'https://opencode.ai/config.json',
|
|
16
|
+
formatter: true,
|
|
17
|
+
lsp: false,
|
|
18
|
+
instructions,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
if (plugins.length > 0) {
|
|
22
|
+
config.plugin = plugins;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const references = {};
|
|
26
|
+
if (selections.standards?.length) {
|
|
27
|
+
references.standards = {
|
|
28
|
+
path: 'references/standards',
|
|
29
|
+
description: 'Canonical engineering standards referenced by skills.',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (selections.templates?.length) {
|
|
33
|
+
references.templates = {
|
|
34
|
+
path: 'references/templates',
|
|
35
|
+
description: 'Ready-to-use workflow documents.',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (Object.keys(references).length > 0) {
|
|
39
|
+
config.references = references;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (Object.keys(mcpEntries).length > 0) {
|
|
43
|
+
config.mcp = mcpEntries;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return JSON.stringify(config, null, 4);
|
|
47
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export const TUI_THEMES = [
|
|
2
|
+
'system',
|
|
3
|
+
'tokyonight',
|
|
4
|
+
'everforest',
|
|
5
|
+
'ayu',
|
|
6
|
+
'catppuccin',
|
|
7
|
+
'catppuccin-macchiato',
|
|
8
|
+
'gruvbox',
|
|
9
|
+
'kanagawa',
|
|
10
|
+
'nord',
|
|
11
|
+
'matrix',
|
|
12
|
+
'one-dark',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const TUI_DEFAULTS = {
|
|
16
|
+
theme: 'system',
|
|
17
|
+
scroll_speed: 3,
|
|
18
|
+
scroll_acceleration: true,
|
|
19
|
+
diff_style: 'auto',
|
|
20
|
+
mouse: true,
|
|
21
|
+
cursor: { style: 'block', blinking: true },
|
|
22
|
+
attention: { enabled: true, notifications: true, sound: true, volume: 0.4 },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function normalizeTuiPreferences(preferences = {}) {
|
|
26
|
+
const source = preferences || {};
|
|
27
|
+
const cursor = source.cursor || {};
|
|
28
|
+
const attention = source.attention || {};
|
|
29
|
+
const scrollSpeed = Number(source.scroll_speed);
|
|
30
|
+
const volume = Number(attention.volume);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
theme: typeof source.theme === 'string' && source.theme ? source.theme : TUI_DEFAULTS.theme,
|
|
34
|
+
scroll_speed: Number.isFinite(scrollSpeed) && scrollSpeed >= 0.001 ? scrollSpeed : TUI_DEFAULTS.scroll_speed,
|
|
35
|
+
scroll_acceleration: typeof source.scroll_acceleration === 'boolean' ? source.scroll_acceleration : TUI_DEFAULTS.scroll_acceleration,
|
|
36
|
+
diff_style: source.diff_style === 'stacked' ? 'stacked' : 'auto',
|
|
37
|
+
mouse: typeof source.mouse === 'boolean' ? source.mouse : TUI_DEFAULTS.mouse,
|
|
38
|
+
cursor: {
|
|
39
|
+
style: ['block', 'underline', 'line', 'default'].includes(cursor.style) ? cursor.style : TUI_DEFAULTS.cursor.style,
|
|
40
|
+
blinking: typeof cursor.blinking === 'boolean' ? cursor.blinking : TUI_DEFAULTS.cursor.blinking,
|
|
41
|
+
},
|
|
42
|
+
attention: {
|
|
43
|
+
enabled: typeof attention.enabled === 'boolean' ? attention.enabled : TUI_DEFAULTS.attention.enabled,
|
|
44
|
+
notifications: typeof attention.notifications === 'boolean' ? attention.notifications : TUI_DEFAULTS.attention.notifications,
|
|
45
|
+
sound: typeof attention.sound === 'boolean' ? attention.sound : TUI_DEFAULTS.attention.sound,
|
|
46
|
+
volume: Number.isFinite(volume) && volume >= 0 && volume <= 1 ? volume : TUI_DEFAULTS.attention.volume,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function tuiPreferencesFromConfig(config) {
|
|
52
|
+
if (!config || typeof config !== 'object') return {};
|
|
53
|
+
const preferences = {};
|
|
54
|
+
if (typeof config.theme === 'string') preferences.theme = config.theme;
|
|
55
|
+
if (typeof config.scroll_speed === 'number') preferences.scroll_speed = config.scroll_speed;
|
|
56
|
+
if (typeof config.scroll_acceleration?.enabled === 'boolean') preferences.scroll_acceleration = config.scroll_acceleration.enabled;
|
|
57
|
+
if (typeof config.diff_style === 'string') preferences.diff_style = config.diff_style;
|
|
58
|
+
if (typeof config.mouse === 'boolean') preferences.mouse = config.mouse;
|
|
59
|
+
if (config.cursor && typeof config.cursor === 'object') preferences.cursor = config.cursor;
|
|
60
|
+
if (config.attention && typeof config.attention === 'object') preferences.attention = config.attention;
|
|
61
|
+
return preferences;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function generateTuiConfig({ selections, preferences }) {
|
|
65
|
+
const prefs = normalizeTuiPreferences(preferences);
|
|
66
|
+
const config = {
|
|
67
|
+
$schema: 'https://opencode.ai/tui.json',
|
|
68
|
+
theme: prefs.theme,
|
|
69
|
+
scroll_speed: prefs.scroll_speed,
|
|
70
|
+
scroll_acceleration: {
|
|
71
|
+
enabled: prefs.scroll_acceleration,
|
|
72
|
+
},
|
|
73
|
+
diff_style: prefs.diff_style,
|
|
74
|
+
cursor: {
|
|
75
|
+
style: prefs.cursor.style,
|
|
76
|
+
blinking: prefs.cursor.blinking,
|
|
77
|
+
},
|
|
78
|
+
mouse: prefs.mouse,
|
|
79
|
+
attention: {
|
|
80
|
+
enabled: prefs.attention.enabled,
|
|
81
|
+
notifications: prefs.attention.notifications,
|
|
82
|
+
sound: prefs.attention.sound,
|
|
83
|
+
volume: prefs.attention.volume,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (selections.plugins?.includes('opencode-goal-plugin')) {
|
|
88
|
+
config.plugin = ['@prevalentware/opencode-goal-plugin'];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return JSON.stringify(config, null, 4);
|
|
92
|
+
}
|
package/src/doctor.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { access, readFile } from 'node:fs/promises';
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
4
3
|
|
|
5
4
|
import { categories } from './catalog.js';
|
|
6
|
-
import {
|
|
5
|
+
import { hash } from './hash.js';
|
|
6
|
+
import { itemRelativePath } from './item-layout.js';
|
|
7
|
+
import { loadLockFile, lockToSelections } from './install/lock.js';
|
|
8
|
+
import { isMissing } from './paths.js';
|
|
9
|
+
import { status } from './ui.js';
|
|
7
10
|
|
|
8
11
|
async function exists(path) {
|
|
9
12
|
try {
|
|
@@ -14,6 +17,16 @@ async function exists(path) {
|
|
|
14
17
|
}
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
async function checkManagedFile(absTarget, relativePath, expectedHash, issues) {
|
|
21
|
+
try {
|
|
22
|
+
const actual = hash(await readFile(resolve(absTarget, relativePath)));
|
|
23
|
+
if (actual !== expectedHash) issues.push(`Modified managed file: ${relativePath}`);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (isMissing(error)) issues.push(`Missing managed file: ${relativePath}`);
|
|
26
|
+
else throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
export async function inspectInstallation(targetDir) {
|
|
18
31
|
const absTarget = resolve(process.cwd(), targetDir);
|
|
19
32
|
const issues = [];
|
|
@@ -32,26 +45,14 @@ export async function inspectInstallation(targetDir) {
|
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
for (const [path, entry] of Object.entries(lock.generated || {})) {
|
|
35
|
-
|
|
36
|
-
const actual = createHash('sha256').update(await readFile(resolve(absTarget, path))).digest('hex');
|
|
37
|
-
if (actual !== entry.computedHash) issues.push(`Modified managed file: ${path}`);
|
|
38
|
-
} catch (error) {
|
|
39
|
-
if (error.code === 'ENOENT') issues.push(`Missing managed file: ${path}`);
|
|
40
|
-
else throw error;
|
|
41
|
-
}
|
|
48
|
+
await checkManagedFile(absTarget, path, entry.computedHash, issues);
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
for (const [category, entries] of Object.entries(lock)) {
|
|
45
52
|
if (!categories[category]) continue;
|
|
46
|
-
for (const
|
|
53
|
+
for (const entry of Object.values(entries)) {
|
|
47
54
|
for (const [path, expected] of Object.entries(entry.files || {})) {
|
|
48
|
-
|
|
49
|
-
const actual = createHash('sha256').update(await readFile(resolve(absTarget, path))).digest('hex');
|
|
50
|
-
if (actual !== expected) issues.push(`Modified managed file: ${path}`);
|
|
51
|
-
} catch (error) {
|
|
52
|
-
if (error.code === 'ENOENT') issues.push(`Missing managed file: ${path}`);
|
|
53
|
-
else throw error;
|
|
54
|
-
}
|
|
55
|
+
await checkManagedFile(absTarget, path, expected, issues);
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
}
|
|
@@ -60,10 +61,7 @@ export async function inspectInstallation(targetDir) {
|
|
|
60
61
|
const config = categories[category];
|
|
61
62
|
for (const id of ids) {
|
|
62
63
|
const item = config.items.find(entry => entry.id === id);
|
|
63
|
-
|
|
64
|
-
? `${config.sourceDir.replace(/^framework\//, '')}/${id}.md`
|
|
65
|
-
: `${config.sourceDir.replace(/^framework\//, '')}/${id}`;
|
|
66
|
-
if (!item || !(await exists(resolve(absTarget, relativePath)))) {
|
|
64
|
+
if (!item || !(await exists(resolve(absTarget, itemRelativePath(category, id))))) {
|
|
67
65
|
issues.push(`Missing installed ${category} item: ${id}`);
|
|
68
66
|
}
|
|
69
67
|
}
|
|
@@ -89,11 +87,12 @@ export async function inspectInstallation(targetDir) {
|
|
|
89
87
|
|
|
90
88
|
export async function doctor(targetDir = '.opencode', output = console.log) {
|
|
91
89
|
const result = await inspectInstallation(targetDir);
|
|
92
|
-
output(`Checking ${result.targetDir}`);
|
|
90
|
+
output(status('info', `Checking ${result.targetDir}`));
|
|
93
91
|
if (result.issues.length === 0) {
|
|
94
|
-
output('No issues found.');
|
|
92
|
+
output(status('success', 'No issues found.'));
|
|
95
93
|
return true;
|
|
96
94
|
}
|
|
97
|
-
|
|
95
|
+
output(status('error', `${result.issues.length} issue${result.issues.length === 1 ? '' : 's'} found:`));
|
|
96
|
+
for (const issue of result.issues) output(` ${status('warn', issue)}`);
|
|
98
97
|
return false;
|
|
99
98
|
}
|
package/src/hash.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { hash } from '../hash.js';
|
|
5
|
+
import { assertSafeDestination, isMissing, resolveSource } from '../paths.js';
|
|
6
|
+
|
|
7
|
+
export async function collectMcpEnvExamples(mcpIds) {
|
|
8
|
+
const combined = new Map();
|
|
9
|
+
for (const id of mcpIds) {
|
|
10
|
+
try {
|
|
11
|
+
const examplePath = resolveSource(`framework/mcps/${id}/configs/.env.example`);
|
|
12
|
+
const content = await readFile(examplePath, 'utf-8');
|
|
13
|
+
const parsed = parseEnv(content);
|
|
14
|
+
for (const [key, value] of parsed) {
|
|
15
|
+
if (!combined.has(key)) combined.set(key, value);
|
|
16
|
+
}
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (!isMissing(error)) throw error;
|
|
19
|
+
// MCPs may not provide an environment example.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return combined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function writeMergedEnv(absTarget, examples, options) {
|
|
26
|
+
if (examples.size === 0) return;
|
|
27
|
+
|
|
28
|
+
const envPath = resolve(absTarget, '.env');
|
|
29
|
+
await assertSafeDestination(options.targetDir, envPath);
|
|
30
|
+
const existing = new Map();
|
|
31
|
+
let existingContent = '';
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
existingContent = await readFile(envPath, 'utf-8');
|
|
35
|
+
const parsed = parseEnv(existingContent);
|
|
36
|
+
for (const [key, value] of parsed) existing.set(key, value);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (!isMissing(error)) throw error;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const additions = [];
|
|
42
|
+
for (const [key, value] of examples) {
|
|
43
|
+
if (!existing.has(key)) additions.push(`${key}=${value}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (additions.length === 0) return;
|
|
47
|
+
const separator = existingContent && !existingContent.endsWith('\n') ? '\n' : '';
|
|
48
|
+
const content = Buffer.from(`${existingContent}${separator}${additions.join('\n')}\n`);
|
|
49
|
+
if (!options.dryRun) await writeFile(envPath, content);
|
|
50
|
+
options.managedFiles['.env'] = hash(content);
|
|
51
|
+
options.fileOwners['.env'] = { generated: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseEnv(content) {
|
|
55
|
+
const vars = new Map();
|
|
56
|
+
for (const line of content.split('\n')) {
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
59
|
+
const eqIndex = trimmed.indexOf('=');
|
|
60
|
+
if (eqIndex === -1) {
|
|
61
|
+
vars.set(trimmed, '');
|
|
62
|
+
} else {
|
|
63
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
64
|
+
const value = trimmed.slice(eqIndex + 1).trim();
|
|
65
|
+
if (key) vars.set(key, value);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return vars;
|
|
69
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { categories } from '../catalog.js';
|
|
5
|
+
import { hash } from '../hash.js';
|
|
6
|
+
import {
|
|
7
|
+
isCopyable,
|
|
8
|
+
isFileBased,
|
|
9
|
+
isRemoved,
|
|
10
|
+
itemRelativePath,
|
|
11
|
+
itemSourcePath,
|
|
12
|
+
targetSubdir,
|
|
13
|
+
} from '../item-layout.js';
|
|
14
|
+
import { assertSafeDestination, isMissing, resolveSource } from '../paths.js';
|
|
15
|
+
import { status } from '../ui.js';
|
|
16
|
+
import { getExpectedHash } from './lock.js';
|
|
17
|
+
|
|
18
|
+
async function canWrite(destFile, relativePath, oldLock, force) {
|
|
19
|
+
if (force) return true;
|
|
20
|
+
try {
|
|
21
|
+
const existing = await readFile(destFile);
|
|
22
|
+
const previousHash = getExpectedHash(oldLock, relativePath);
|
|
23
|
+
return Boolean(previousHash && previousHash === hash(existing));
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (isMissing(error)) return true;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function writeManagedFile(destFile, content, relativePath, options, owner) {
|
|
31
|
+
await assertSafeDestination(options.targetDir, destFile);
|
|
32
|
+
if (!options.allowExistingMerge && !(await canWrite(destFile, relativePath, options.oldLock, options.force))) {
|
|
33
|
+
console.warn(status('warn', `Preserving existing file: ${relativePath}`));
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (!options.dryRun) await mkdir(dirname(destFile), { recursive: true });
|
|
37
|
+
if (!options.dryRun) await writeFile(destFile, content);
|
|
38
|
+
options.managedFiles[relativePath] = hash(content);
|
|
39
|
+
if (owner) options.fileOwners[relativePath] = owner;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function copyDir(src, dest, relativeDir, options, owner) {
|
|
44
|
+
if (!options.dryRun) await mkdir(dest, { recursive: true });
|
|
45
|
+
const entries = await readdir(src, { withFileTypes: true });
|
|
46
|
+
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.name === '.DS_Store') continue;
|
|
49
|
+
const srcPath = resolve(src, entry.name);
|
|
50
|
+
const destPath = resolve(dest, entry.name);
|
|
51
|
+
const relativePath = `${relativeDir}/${entry.name}`;
|
|
52
|
+
|
|
53
|
+
if (entry.isDirectory()) {
|
|
54
|
+
await copyDir(srcPath, destPath, relativePath, options, owner);
|
|
55
|
+
} else if (entry.isFile()) {
|
|
56
|
+
const content = await readFile(srcPath);
|
|
57
|
+
await writeManagedFile(destPath, content, relativePath, options, owner);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function copySelectedDirs(targetDir, category, selectedIds, options) {
|
|
63
|
+
const catConfig = categories[category];
|
|
64
|
+
if (!catConfig || !selectedIds?.length) return;
|
|
65
|
+
|
|
66
|
+
const destParent = resolve(targetDir, targetSubdir(catConfig.sourceDir));
|
|
67
|
+
|
|
68
|
+
for (const id of selectedIds) {
|
|
69
|
+
if (isRemoved(category, id)) continue;
|
|
70
|
+
|
|
71
|
+
const source = itemSourcePath(category, id);
|
|
72
|
+
const srcPath = resolveSource(source);
|
|
73
|
+
const destPath = resolve(destParent, id);
|
|
74
|
+
await assertSafeDestination(targetDir, destPath);
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await stat(srcPath);
|
|
78
|
+
await copyDir(srcPath, destPath, itemRelativePath(category, id), options, { category, id });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (isMissing(error)) {
|
|
81
|
+
console.warn(status('warn', `Source not found: ${source}`));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function copySelectedFiles(targetDir, category, selectedIds, options) {
|
|
90
|
+
const catConfig = categories[category];
|
|
91
|
+
if (!catConfig || !selectedIds?.length) return;
|
|
92
|
+
|
|
93
|
+
const destParent = resolve(targetDir, targetSubdir(catConfig.sourceDir));
|
|
94
|
+
if (!options.dryRun) await mkdir(destParent, { recursive: true });
|
|
95
|
+
|
|
96
|
+
for (const id of selectedIds) {
|
|
97
|
+
if (isRemoved(category, id)) continue;
|
|
98
|
+
|
|
99
|
+
const source = itemSourcePath(category, id);
|
|
100
|
+
const relativePath = itemRelativePath(category, id);
|
|
101
|
+
const destFile = resolve(targetDir, relativePath);
|
|
102
|
+
await assertSafeDestination(targetDir, destFile);
|
|
103
|
+
try {
|
|
104
|
+
const content = await readFile(resolveSource(source));
|
|
105
|
+
await writeManagedFile(destFile, content, relativePath, options, { category, id });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (isMissing(error)) {
|
|
108
|
+
console.warn(status('warn', `Source not found: ${source}`));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function deleteSelectedItems(absTarget, category, ids, oldLock, force, dryRun) {
|
|
117
|
+
if (!categories[category] || !ids?.length || !isCopyable(category)) return;
|
|
118
|
+
|
|
119
|
+
for (const id of ids) {
|
|
120
|
+
const relativePath = itemRelativePath(category, id);
|
|
121
|
+
const destPath = resolve(absTarget, relativePath);
|
|
122
|
+
await assertSafeDestination(absTarget, destPath);
|
|
123
|
+
if (!force && oldLock) {
|
|
124
|
+
const managedEntries = Object.entries(oldLock?.[category]?.[id]?.files || {});
|
|
125
|
+
if (managedEntries.length === 0) {
|
|
126
|
+
console.warn(status('warn', `Preserving unmanaged item: ${relativePath}`));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
let modified = false;
|
|
130
|
+
for (const [path, checksum] of managedEntries) {
|
|
131
|
+
await assertSafeDestination(absTarget, resolve(absTarget, path));
|
|
132
|
+
try {
|
|
133
|
+
if (hash(await readFile(resolve(absTarget, path))) !== checksum) modified = true;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (!isMissing(error)) throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (modified) {
|
|
139
|
+
console.warn(status('warn', `Preserving modified item: ${relativePath}`));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!isFileBased(category)) {
|
|
143
|
+
if (!dryRun) {
|
|
144
|
+
for (const [path] of managedEntries) await rm(resolve(absTarget, path), { force: true });
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!dryRun) await rm(destPath, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|