moflo 4.9.18 → 4.9.20
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/.claude/commands/simplify.md +11 -13
- package/.claude/helpers/gate.cjs +100 -5
- package/.claude/helpers/simplify-classify.cjs +4 -1
- package/.claude/skills/simplify/SKILL.md +13 -11
- package/bin/gate.cjs +100 -5
- package/bin/session-start-launcher.mjs +47 -0
- package/bin/simplify-classify.cjs +4 -1
- package/dist/src/cli/commands/spell-credentials.js +248 -0
- package/dist/src/cli/commands/spell.js +5 -3
- package/dist/src/cli/services/moflo-paths.js +5 -0
- package/dist/src/cli/spells/commands/prompt-command.js +45 -0
- package/dist/src/cli/spells/core/prerequisite-checker.js +89 -26
- package/dist/src/cli/spells/core/runner.js +4 -3
- package/dist/src/cli/spells/credentials/credential-store.js +20 -4
- package/dist/src/cli/spells/credentials/default-store.js +126 -0
- package/dist/src/cli/spells/credentials/index.js +6 -0
- package/dist/src/cli/spells/factory/runner-bridge.js +5 -2
- package/dist/src/cli/spells/factory/runner-factory.js +4 -2
- package/dist/src/cli/spells/index.js +1 -1
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spell Credentials CLI
|
|
3
|
+
*
|
|
4
|
+
* `moflo spell credentials list/set/unset/clear/passphrase` — manage the
|
|
5
|
+
* encrypted credential store the spell runner uses for unattended casts.
|
|
6
|
+
*
|
|
7
|
+
* Story #925 (epic #880).
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { output } from '../output.js';
|
|
11
|
+
import { confirm, input } from '../prompt.js';
|
|
12
|
+
import { CredentialStore, CredentialStoreError, MIN_PASSPHRASE_LENGTH, resolveCredentialFilePath, resolvePassphrase, } from '../spells/credentials/index.js';
|
|
13
|
+
/**
|
|
14
|
+
* Build a CredentialStore using the runner's passphrase resolution.
|
|
15
|
+
* Returns null when no passphrase is available — caller surfaces the actionable error.
|
|
16
|
+
*/
|
|
17
|
+
function openStore() {
|
|
18
|
+
const filePath = resolveCredentialFilePath();
|
|
19
|
+
const passphrase = resolvePassphrase();
|
|
20
|
+
if (!passphrase)
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
return new CredentialStore({ filePath, passphrase });
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
output.printError(`Could not open credential store: ${err.message}`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function noStoreError() {
|
|
31
|
+
output.printError('Credential store is locked.');
|
|
32
|
+
output.writeln('');
|
|
33
|
+
output.writeln(' Set MOFLO_CREDENTIALS_PASSPHRASE in your environment, or run:');
|
|
34
|
+
output.writeln(' flo spell credentials passphrase');
|
|
35
|
+
output.writeln('');
|
|
36
|
+
output.writeln(' to initialise a per-machine encryption key.');
|
|
37
|
+
return { success: false, exitCode: 1 };
|
|
38
|
+
}
|
|
39
|
+
const listCommand = {
|
|
40
|
+
name: 'list',
|
|
41
|
+
aliases: ['ls'],
|
|
42
|
+
description: 'List stored credential names (never values)',
|
|
43
|
+
action: async (ctx) => {
|
|
44
|
+
const store = openStore();
|
|
45
|
+
if (!store)
|
|
46
|
+
return noStoreError();
|
|
47
|
+
const meta = await store.list();
|
|
48
|
+
if (ctx.flags.format === 'json') {
|
|
49
|
+
output.printJson(meta);
|
|
50
|
+
return { success: true, data: meta };
|
|
51
|
+
}
|
|
52
|
+
if (meta.length === 0) {
|
|
53
|
+
output.printInfo('No credentials stored. Use `flo spell credentials set <name>` to add one.');
|
|
54
|
+
return { success: true };
|
|
55
|
+
}
|
|
56
|
+
output.writeln();
|
|
57
|
+
output.writeln(output.bold('Stored credentials'));
|
|
58
|
+
output.writeln();
|
|
59
|
+
output.printTable({
|
|
60
|
+
columns: [
|
|
61
|
+
{ key: 'name', header: 'Name', width: 28 },
|
|
62
|
+
{ key: 'description', header: 'Description', width: 35, format: (v) => v ? String(v) : '-' },
|
|
63
|
+
{ key: 'updatedAt', header: 'Updated', width: 22, format: (v) => v ? new Date(String(v)).toLocaleString() : '-' },
|
|
64
|
+
],
|
|
65
|
+
data: meta,
|
|
66
|
+
});
|
|
67
|
+
output.writeln();
|
|
68
|
+
output.printInfo(`${meta.length} credential${meta.length === 1 ? '' : 's'}`);
|
|
69
|
+
return { success: true, data: meta };
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
const setCommand = {
|
|
73
|
+
name: 'set',
|
|
74
|
+
description: 'Store a credential (prompts for the value with hidden input)',
|
|
75
|
+
options: [
|
|
76
|
+
{ name: 'description', short: 'd', description: 'Optional description', type: 'string' },
|
|
77
|
+
],
|
|
78
|
+
examples: [
|
|
79
|
+
{ command: 'moflo spell credentials set GRAPH_ACCESS_TOKEN', description: 'Store the Graph token' },
|
|
80
|
+
{ command: 'moflo spell credentials set SLACK_WEBHOOK_URL --description "OAP target"', description: 'With a description' },
|
|
81
|
+
],
|
|
82
|
+
action: async (ctx) => {
|
|
83
|
+
const name = ctx.args[0];
|
|
84
|
+
if (!name) {
|
|
85
|
+
output.printError('Credential name is required (e.g. `flo spell credentials set TOKEN`)');
|
|
86
|
+
return { success: false, exitCode: 1 };
|
|
87
|
+
}
|
|
88
|
+
const store = openStore();
|
|
89
|
+
if (!store)
|
|
90
|
+
return noStoreError();
|
|
91
|
+
if (!ctx.interactive) {
|
|
92
|
+
output.printError('`set` requires an interactive TTY (the value is entered with hidden input).');
|
|
93
|
+
return { success: false, exitCode: 1 };
|
|
94
|
+
}
|
|
95
|
+
const value = await input({ message: `Value for ${name}:`, mask: true });
|
|
96
|
+
if (!value) {
|
|
97
|
+
output.printError('No value entered — credential not stored.');
|
|
98
|
+
return { success: false, exitCode: 1 };
|
|
99
|
+
}
|
|
100
|
+
const description = ctx.flags.description;
|
|
101
|
+
await store.store(name, value, description);
|
|
102
|
+
output.printSuccess(`Stored credential ${output.highlight(name)}.`);
|
|
103
|
+
return { success: true };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
const unsetCommand = {
|
|
107
|
+
name: 'unset',
|
|
108
|
+
aliases: ['delete', 'rm'],
|
|
109
|
+
description: 'Remove a single credential',
|
|
110
|
+
action: async (ctx) => {
|
|
111
|
+
const name = ctx.args[0];
|
|
112
|
+
if (!name) {
|
|
113
|
+
output.printError('Credential name is required (e.g. `flo spell credentials unset TOKEN`)');
|
|
114
|
+
return { success: false, exitCode: 1 };
|
|
115
|
+
}
|
|
116
|
+
const store = openStore();
|
|
117
|
+
if (!store)
|
|
118
|
+
return noStoreError();
|
|
119
|
+
const removed = await store.delete(name);
|
|
120
|
+
if (!removed) {
|
|
121
|
+
output.printError(`Credential ${output.highlight(name)} not found.`);
|
|
122
|
+
return { success: false, exitCode: 1 };
|
|
123
|
+
}
|
|
124
|
+
output.printSuccess(`Removed credential ${output.highlight(name)}.`);
|
|
125
|
+
return { success: true };
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
const clearCommand = {
|
|
129
|
+
name: 'clear',
|
|
130
|
+
description: 'Remove all stored credentials',
|
|
131
|
+
options: [
|
|
132
|
+
{ name: 'force', short: 'f', description: 'Skip confirmation', type: 'boolean', default: false },
|
|
133
|
+
],
|
|
134
|
+
action: async (ctx) => {
|
|
135
|
+
const store = openStore();
|
|
136
|
+
if (!store)
|
|
137
|
+
return noStoreError();
|
|
138
|
+
const meta = await store.list();
|
|
139
|
+
if (meta.length === 0) {
|
|
140
|
+
output.printInfo('No credentials to clear.');
|
|
141
|
+
return { success: true };
|
|
142
|
+
}
|
|
143
|
+
if (!ctx.flags.force) {
|
|
144
|
+
if (!ctx.interactive) {
|
|
145
|
+
output.printError('Refusing to clear without --force in non-interactive mode.');
|
|
146
|
+
return { success: false, exitCode: 1 };
|
|
147
|
+
}
|
|
148
|
+
const ok = await confirm({
|
|
149
|
+
message: `Delete all ${meta.length} stored credential${meta.length === 1 ? '' : 's'}?`,
|
|
150
|
+
default: false,
|
|
151
|
+
});
|
|
152
|
+
if (!ok) {
|
|
153
|
+
output.printInfo('Cancelled.');
|
|
154
|
+
return { success: true };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const removed = await store.clear();
|
|
158
|
+
output.printSuccess(`Removed ${removed} credential${removed === 1 ? '' : 's'}.`);
|
|
159
|
+
return { success: true };
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
const passphraseCommand = {
|
|
163
|
+
name: 'passphrase',
|
|
164
|
+
aliases: ['init', 'rotate'],
|
|
165
|
+
description: 'Initialise the credential store passphrase, or rotate an existing one',
|
|
166
|
+
action: async (ctx) => {
|
|
167
|
+
if (!ctx.interactive) {
|
|
168
|
+
output.printError('`passphrase` requires an interactive TTY.');
|
|
169
|
+
return { success: false, exitCode: 1 };
|
|
170
|
+
}
|
|
171
|
+
const filePath = resolveCredentialFilePath();
|
|
172
|
+
const exists = existsSync(filePath);
|
|
173
|
+
const minHint = `(min ${MIN_PASSPHRASE_LENGTH} chars)`;
|
|
174
|
+
if (!exists) {
|
|
175
|
+
const newPass = await input({ message: `Choose a passphrase ${minHint}:`, mask: true });
|
|
176
|
+
const confirmed = await input({ message: 'Confirm passphrase:', mask: true });
|
|
177
|
+
if (newPass !== confirmed) {
|
|
178
|
+
output.printError('Passphrases do not match.');
|
|
179
|
+
return { success: false, exitCode: 1 };
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const store = new CredentialStore({ filePath, passphrase: newPass });
|
|
183
|
+
// Force a salted file write so the chosen passphrase locks the store.
|
|
184
|
+
await store.store('__init__', 'init');
|
|
185
|
+
await store.delete('__init__');
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
if (err instanceof CredentialStoreError && err.code === 'WEAK_PASSPHRASE') {
|
|
189
|
+
output.printError(err.message);
|
|
190
|
+
return { success: false, exitCode: 1 };
|
|
191
|
+
}
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
output.printSuccess(`Initialised credential store at ${filePath}`);
|
|
195
|
+
output.printInfo('Set MOFLO_CREDENTIALS_PASSPHRASE in your environment to keep schedules running unattended.');
|
|
196
|
+
return { success: true };
|
|
197
|
+
}
|
|
198
|
+
const oldPass = await input({ message: 'Current passphrase:', mask: true });
|
|
199
|
+
const newPass = await input({ message: `New passphrase ${minHint}:`, mask: true });
|
|
200
|
+
const confirmed = await input({ message: 'Confirm new passphrase:', mask: true });
|
|
201
|
+
if (newPass !== confirmed) {
|
|
202
|
+
output.printError('Passphrases do not match.');
|
|
203
|
+
return { success: false, exitCode: 1 };
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const store = new CredentialStore({ filePath, passphrase: oldPass });
|
|
207
|
+
await store.rotate(oldPass, newPass);
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
if (err instanceof CredentialStoreError) {
|
|
211
|
+
output.printError(err.message);
|
|
212
|
+
return { success: false, exitCode: 1 };
|
|
213
|
+
}
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
output.printSuccess('Passphrase rotated. Update MOFLO_CREDENTIALS_PASSPHRASE if you set it in your environment.');
|
|
217
|
+
return { success: true };
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
export const spellCredentialsCommand = {
|
|
221
|
+
name: 'credentials',
|
|
222
|
+
aliases: ['creds'],
|
|
223
|
+
description: 'Manage the encrypted credential store used for unattended spell casts',
|
|
224
|
+
subcommands: [listCommand, setCommand, unsetCommand, clearCommand, passphraseCommand],
|
|
225
|
+
options: [],
|
|
226
|
+
examples: [
|
|
227
|
+
{ command: 'moflo spell credentials list', description: 'Show stored credential names' },
|
|
228
|
+
{ command: 'moflo spell credentials set GRAPH_TOKEN', description: 'Store a credential' },
|
|
229
|
+
{ command: 'moflo spell credentials unset GRAPH_TOKEN', description: 'Remove a credential' },
|
|
230
|
+
{ command: 'moflo spell credentials clear --force', description: 'Wipe all credentials' },
|
|
231
|
+
],
|
|
232
|
+
action: async () => {
|
|
233
|
+
output.writeln();
|
|
234
|
+
output.writeln(output.bold('Spell Credentials'));
|
|
235
|
+
output.writeln();
|
|
236
|
+
output.writeln('Usage: moflo spell credentials <subcommand>');
|
|
237
|
+
output.writeln();
|
|
238
|
+
output.printList([
|
|
239
|
+
`${output.highlight('list')} - List stored credential names`,
|
|
240
|
+
`${output.highlight('set')} - Store a credential (hidden input)`,
|
|
241
|
+
`${output.highlight('unset')} - Remove a single credential`,
|
|
242
|
+
`${output.highlight('clear')} - Remove all stored credentials`,
|
|
243
|
+
`${output.highlight('passphrase')} - Initialise or rotate the passphrase`,
|
|
244
|
+
]);
|
|
245
|
+
return { success: true };
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
//# sourceMappingURL=spell-credentials.js.map
|
|
@@ -13,6 +13,7 @@ import { callMCPTool } from '../mcp-client.js';
|
|
|
13
13
|
import { TOOL_SPELL_CAST, TOOL_SPELL_LIST, TOOL_SPELL_STATUS, TOOL_SPELL_CANCEL, TOOL_SPELL_TEMPLATE, } from '../mcp-tools/tool-names.js';
|
|
14
14
|
import { formatStatus, handleMCPError } from '../services/cli-formatters.js';
|
|
15
15
|
import { scheduleCommand } from './spell-schedule.js';
|
|
16
|
+
import { spellCredentialsCommand } from './spell-credentials.js';
|
|
16
17
|
import { loadSpellEngine } from '../services/engine-loader.js';
|
|
17
18
|
// Re-export formatStatus as formatStageStatus for table column references
|
|
18
19
|
const formatStageStatus = formatStatus;
|
|
@@ -587,7 +588,7 @@ export const spellCommand = {
|
|
|
587
588
|
name: 'spell',
|
|
588
589
|
aliases: ['workflow'],
|
|
589
590
|
description: 'Spell casting and management',
|
|
590
|
-
subcommands: [castCommand, validateCommand, listCommand, statusCommand, stopCommand, templateCommand, scheduleCommand],
|
|
591
|
+
subcommands: [castCommand, validateCommand, listCommand, statusCommand, stopCommand, templateCommand, scheduleCommand, spellCredentialsCommand],
|
|
591
592
|
options: [],
|
|
592
593
|
examples: [
|
|
593
594
|
{ command: 'moflo spell cast -n development', description: 'Cast spell by name' },
|
|
@@ -609,8 +610,9 @@ export const spellCommand = {
|
|
|
609
610
|
`${output.highlight('list')} - List spells (grimoire + casts)`,
|
|
610
611
|
`${output.highlight('status')} - Show spell status`,
|
|
611
612
|
`${output.highlight('stop')} - Dispel a running spell`,
|
|
612
|
-
`${output.highlight('template')}
|
|
613
|
-
`${output.highlight('schedule')}
|
|
613
|
+
`${output.highlight('template')} - Browse grimoire templates`,
|
|
614
|
+
`${output.highlight('schedule')} - Manage scheduled spells`,
|
|
615
|
+
`${output.highlight('credentials')} - Manage stored secrets for unattended casts`,
|
|
614
616
|
]);
|
|
615
617
|
output.writeln();
|
|
616
618
|
output.writeln('Run "moflo spell <subcommand> --help" for more info');
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* `cli/services/cherry-pick-learnings.ts` and is dynamically imported from
|
|
20
20
|
* the compiled `dist/` by the launcher.
|
|
21
21
|
*/
|
|
22
|
+
import { homedir } from 'node:os';
|
|
22
23
|
import { join } from 'node:path';
|
|
23
24
|
export const MOFLO_DIR = '.moflo';
|
|
24
25
|
/** Canonical memory DB filename (post-#727). Lives at `<root>/.moflo/moflo.db`. */
|
|
@@ -39,6 +40,10 @@ export const LEGACY_MEMORY_DB_BAK_SUFFIX = '.bak';
|
|
|
39
40
|
export function mofloDir(projectRoot) {
|
|
40
41
|
return join(projectRoot, MOFLO_DIR);
|
|
41
42
|
}
|
|
43
|
+
/** User-scope MoFlo state dir: `~/.moflo`. Holds credentials and other per-machine state. */
|
|
44
|
+
export function mofloHomeDir() {
|
|
45
|
+
return join(homedir(), MOFLO_DIR);
|
|
46
|
+
}
|
|
42
47
|
export function legacyClaudeFlowDir(projectRoot) {
|
|
43
48
|
return join(projectRoot, LEGACY_CLAUDE_FLOW_DIR);
|
|
44
49
|
}
|
|
@@ -46,6 +46,10 @@ export const promptCommand = {
|
|
|
46
46
|
type: 'number',
|
|
47
47
|
description: 'Used when default resolves to empty/null — produces ISO timestamp N days ago',
|
|
48
48
|
},
|
|
49
|
+
saveAs: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: 'Persist the response under this name in the credential store; later casts read it back without prompting',
|
|
52
|
+
},
|
|
49
53
|
},
|
|
50
54
|
required: ['message'],
|
|
51
55
|
},
|
|
@@ -65,11 +69,41 @@ export const promptCommand = {
|
|
|
65
69
|
if (config.fallbackDaysAgo !== undefined && typeof config.fallbackDaysAgo !== 'number') {
|
|
66
70
|
errors.push({ path: 'fallbackDaysAgo', message: 'fallbackDaysAgo must be a number' });
|
|
67
71
|
}
|
|
72
|
+
if (config.saveAs !== undefined && typeof config.saveAs !== 'string') {
|
|
73
|
+
errors.push({ path: 'saveAs', message: 'saveAs must be a string' });
|
|
74
|
+
}
|
|
75
|
+
if (typeof config.saveAs === 'string' && config.saveAs.trim().length === 0) {
|
|
76
|
+
errors.push({ path: 'saveAs', message: 'saveAs cannot be empty' });
|
|
77
|
+
}
|
|
68
78
|
return { valid: errors.length === 0, errors };
|
|
69
79
|
},
|
|
70
80
|
async execute(config, context) {
|
|
71
81
|
const start = Date.now();
|
|
72
82
|
const message = interpolateString(config.message, context);
|
|
83
|
+
const saveAs = config.saveAs?.trim();
|
|
84
|
+
if (saveAs) {
|
|
85
|
+
try {
|
|
86
|
+
const stored = await context.credentials.get(saveAs);
|
|
87
|
+
if (typeof stored === 'string' && stored.length > 0) {
|
|
88
|
+
return {
|
|
89
|
+
success: true,
|
|
90
|
+
data: {
|
|
91
|
+
message,
|
|
92
|
+
options: config.options ?? null,
|
|
93
|
+
outputVar: config.outputVar ?? 'response',
|
|
94
|
+
response: stored,
|
|
95
|
+
default: '',
|
|
96
|
+
interactive: false,
|
|
97
|
+
fromStore: true,
|
|
98
|
+
},
|
|
99
|
+
duration: Date.now() - start,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Store unavailable — fall through to normal prompt path.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
73
107
|
// Interpolate default — pure-ref interpolation may yield null; tolerate it.
|
|
74
108
|
let interpolatedDefault = null;
|
|
75
109
|
if (typeof config.default === 'string') {
|
|
@@ -96,6 +130,15 @@ export const promptCommand = {
|
|
|
96
130
|
lock.release();
|
|
97
131
|
}
|
|
98
132
|
}
|
|
133
|
+
// Best-effort persist — a failed save shouldn't fail a cast that already has a working answer.
|
|
134
|
+
if (saveAs && interactive && response) {
|
|
135
|
+
try {
|
|
136
|
+
await context.credentials.store(saveAs, response);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
console.warn(`[moflo:credentials] could not persist "${saveAs}": ${err.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
99
142
|
return {
|
|
100
143
|
success: true,
|
|
101
144
|
data: {
|
|
@@ -105,6 +148,7 @@ export const promptCommand = {
|
|
|
105
148
|
response,
|
|
106
149
|
default: effectiveDefault,
|
|
107
150
|
interactive,
|
|
151
|
+
fromStore: false,
|
|
108
152
|
},
|
|
109
153
|
duration: Date.now() - start,
|
|
110
154
|
};
|
|
@@ -116,6 +160,7 @@ export const promptCommand = {
|
|
|
116
160
|
{ name: 'outputVar', type: 'string' },
|
|
117
161
|
{ name: 'default', type: 'string', description: 'Effective default after fallback chain' },
|
|
118
162
|
{ name: 'interactive', type: 'boolean', description: 'Whether the user was actually prompted' },
|
|
163
|
+
{ name: 'fromStore', type: 'boolean', description: 'True when the response was returned from the credential store' },
|
|
119
164
|
];
|
|
120
165
|
},
|
|
121
166
|
};
|
|
@@ -151,17 +151,25 @@ export function formatPrerequisiteErrors(results) {
|
|
|
151
151
|
return '';
|
|
152
152
|
const lines = ['Missing prerequisites:'];
|
|
153
153
|
for (const f of failed) {
|
|
154
|
-
lines
|
|
155
|
-
if (f.url)
|
|
156
|
-
lines.push(` ${f.url}`);
|
|
154
|
+
appendPrereqLine(lines, f.name, f.installHint, f.url);
|
|
157
155
|
}
|
|
158
156
|
return lines.join('\n');
|
|
159
157
|
}
|
|
158
|
+
function appendPrereqLine(lines, name, hint, url) {
|
|
159
|
+
const hintSuffix = hint ? `: ${hint}` : '';
|
|
160
|
+
lines.push(` - ${name}${hintSuffix}`);
|
|
161
|
+
if (url)
|
|
162
|
+
lines.push(` ${url}`);
|
|
163
|
+
}
|
|
160
164
|
/**
|
|
161
|
-
* Evaluate all prereqs.
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
+
* Evaluate all prereqs. Resolution chain for env-type prereqs:
|
|
166
|
+
* 1. process.env[key] already set → satisfied.
|
|
167
|
+
* 2. credentials.get(key) returns a value → write to process.env, satisfied.
|
|
168
|
+
* 3. TTY interactive → prompt → write to process.env AND credentials.store.
|
|
169
|
+
* 4. Non-TTY or no credentials → fail fast with `errorCode: 'MISSING_CREDENTIAL'`.
|
|
170
|
+
*
|
|
171
|
+
* Non-env prereqs (`command`, `file`) bypass the credential chain and surface
|
|
172
|
+
* through the standard "Missing prerequisites" path.
|
|
165
173
|
*/
|
|
166
174
|
export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
167
175
|
if (prerequisites.length === 0) {
|
|
@@ -170,22 +178,58 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
170
178
|
const interactive = options.interactive
|
|
171
179
|
?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
172
180
|
const log = options.log ?? ((line) => console.log(line));
|
|
181
|
+
const credentials = options.credentials;
|
|
173
182
|
const initial = await checkPrerequisites(prerequisites);
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
183
|
+
const unmetIndices = initial.flatMap((r, i) => r.satisfied ? [] : [i]);
|
|
184
|
+
if (unmetIndices.length === 0) {
|
|
176
185
|
return { ok: true, resolvedNames: [] };
|
|
177
186
|
}
|
|
178
|
-
|
|
187
|
+
// Pull env-type prereqs from the store in parallel — each resolved index
|
|
188
|
+
// lets us skip a re-check of the cheap env detector.
|
|
189
|
+
const resolvedFromStoreNames = [];
|
|
190
|
+
const storeResolved = new Set();
|
|
191
|
+
if (credentials) {
|
|
192
|
+
await Promise.all(unmetIndices.map(async (i) => {
|
|
193
|
+
const prereq = prerequisites[i];
|
|
194
|
+
if (!prereq.envKey)
|
|
195
|
+
return;
|
|
196
|
+
const stored = await credentials.get(prereq.envKey);
|
|
197
|
+
if (typeof stored === 'string' && stored.length > 0) {
|
|
198
|
+
process.env[prereq.envKey] = stored;
|
|
199
|
+
storeResolved.add(i);
|
|
200
|
+
resolvedFromStoreNames.push(prereq.name);
|
|
201
|
+
}
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
const stillUnmetIdx = unmetIndices.filter(i => !storeResolved.has(i));
|
|
205
|
+
if (stillUnmetIdx.length === 0) {
|
|
206
|
+
return { ok: true, resolvedNames: resolvedFromStoreNames };
|
|
207
|
+
}
|
|
208
|
+
const stillUnmet = stillUnmetIdx.map(i => prerequisites[i]);
|
|
209
|
+
const promptable = stillUnmet.filter(p => interactive && p.promptOnMissing === true && typeof p.envKey === 'string');
|
|
179
210
|
if (!interactive || promptable.length === 0) {
|
|
211
|
+
const promptableEnvKeys = stillUnmet
|
|
212
|
+
.filter(p => p.promptOnMissing === true && typeof p.envKey === 'string')
|
|
213
|
+
.map(p => p.envKey);
|
|
214
|
+
if (promptableEnvKeys.length > 0) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
message: formatMissingCredentialMessage(promptableEnvKeys, stillUnmet),
|
|
218
|
+
resolvedNames: resolvedFromStoreNames,
|
|
219
|
+
errorCode: 'MISSING_CREDENTIAL',
|
|
220
|
+
missingCredentials: promptableEnvKeys,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
180
223
|
return {
|
|
181
224
|
ok: false,
|
|
182
|
-
message: formatPrerequisiteErrors(initial),
|
|
183
|
-
resolvedNames:
|
|
225
|
+
message: formatPrerequisiteErrors(stillUnmetIdx.map(i => initial[i])),
|
|
226
|
+
resolvedNames: resolvedFromStoreNames,
|
|
184
227
|
};
|
|
185
228
|
}
|
|
186
|
-
printPreflightBanner(log,
|
|
229
|
+
printPreflightBanner(log, stillUnmet.length);
|
|
187
230
|
const promptLine = options.promptLine ?? readLineFromStdin;
|
|
188
|
-
const
|
|
231
|
+
const promptedNames = [];
|
|
232
|
+
const promptableSet = new Set(promptable);
|
|
189
233
|
const lock = acquireTTYLock();
|
|
190
234
|
try {
|
|
191
235
|
for (const prereq of promptable) {
|
|
@@ -193,7 +237,7 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
193
237
|
return {
|
|
194
238
|
ok: false,
|
|
195
239
|
message: 'Prerequisite resolution aborted',
|
|
196
|
-
resolvedNames,
|
|
240
|
+
resolvedNames: [...resolvedFromStoreNames, ...promptedNames],
|
|
197
241
|
};
|
|
198
242
|
}
|
|
199
243
|
if (prereq.description)
|
|
@@ -209,37 +253,56 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
209
253
|
return {
|
|
210
254
|
ok: false,
|
|
211
255
|
message: `Prerequisite "${prereq.name}" prompt failed: ${err.message}`,
|
|
212
|
-
resolvedNames,
|
|
256
|
+
resolvedNames: [...resolvedFromStoreNames, ...promptedNames],
|
|
213
257
|
};
|
|
214
258
|
}
|
|
215
259
|
if (!answer || answer.length === 0) {
|
|
216
260
|
return {
|
|
217
261
|
ok: false,
|
|
218
262
|
message: `Prerequisite "${prereq.name}" was not provided`,
|
|
219
|
-
resolvedNames,
|
|
263
|
+
resolvedNames: [...resolvedFromStoreNames, ...promptedNames],
|
|
220
264
|
};
|
|
221
265
|
}
|
|
222
266
|
if (prereq.envKey) {
|
|
223
267
|
process.env[prereq.envKey] = answer;
|
|
268
|
+
if (credentials) {
|
|
269
|
+
try {
|
|
270
|
+
await credentials.store(prereq.envKey, answer);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
log(` (could not persist credential "${prereq.envKey}": ${err.message})`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
224
276
|
}
|
|
225
|
-
|
|
277
|
+
promptedNames.push(prereq.name);
|
|
226
278
|
}
|
|
227
279
|
}
|
|
228
280
|
finally {
|
|
229
281
|
lock.release();
|
|
230
282
|
}
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
if (stillUnmet.length > 0) {
|
|
283
|
+
// Anything in stillUnmet that wasn't promptable (typically command/file
|
|
284
|
+
// prereqs) is still broken — carry forward the initial detector result.
|
|
285
|
+
const unfixableIdx = stillUnmetIdx.filter(i => !promptableSet.has(prerequisites[i]));
|
|
286
|
+
if (unfixableIdx.length > 0) {
|
|
236
287
|
return {
|
|
237
288
|
ok: false,
|
|
238
|
-
message: formatPrerequisiteErrors(
|
|
239
|
-
resolvedNames,
|
|
289
|
+
message: formatPrerequisiteErrors(unfixableIdx.map(i => initial[i])),
|
|
290
|
+
resolvedNames: [...resolvedFromStoreNames, ...promptedNames],
|
|
240
291
|
};
|
|
241
292
|
}
|
|
242
|
-
return { ok: true, resolvedNames };
|
|
293
|
+
return { ok: true, resolvedNames: [...resolvedFromStoreNames, ...promptedNames] };
|
|
294
|
+
}
|
|
295
|
+
function formatMissingCredentialMessage(envKeys, prereqs) {
|
|
296
|
+
const lines = ['Missing credentials (cannot prompt — non-interactive run):'];
|
|
297
|
+
for (const key of envKeys) {
|
|
298
|
+
const prereq = prereqs.find(p => p.envKey === key);
|
|
299
|
+
const label = `${prereq?.name ?? key} (${key})`;
|
|
300
|
+
appendPrereqLine(lines, label, prereq?.installHint, prereq?.url);
|
|
301
|
+
}
|
|
302
|
+
lines.push('');
|
|
303
|
+
lines.push('Prime these by casting the spell once interactively, or run:');
|
|
304
|
+
lines.push(' flo spell credentials set <name>');
|
|
305
|
+
return lines.join('\n');
|
|
243
306
|
}
|
|
244
307
|
function printPreflightBanner(log, unmetCount) {
|
|
245
308
|
log('');
|
|
@@ -99,17 +99,18 @@ export class SpellCaster {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
-
// Pre-flight prerequisite checks — walks YAML + step-command
|
|
103
|
-
//
|
|
102
|
+
// Pre-flight prerequisite checks (issue #460) — walks YAML + step-command
|
|
103
|
+
// sources, pulls from credential store, prompts on TTY, persists answers.
|
|
104
104
|
if (!options.dryRun) {
|
|
105
105
|
const prerequisites = collectPrerequisites(definition, this.registry);
|
|
106
106
|
if (prerequisites.length > 0) {
|
|
107
107
|
const resolution = await resolveUnmetPrerequisites(prerequisites, {
|
|
108
108
|
abortSignal: options.signal,
|
|
109
|
+
credentials: this.credentials,
|
|
109
110
|
});
|
|
110
111
|
if (!resolution.ok) {
|
|
111
112
|
return this.failureResult(spellId, startTime, [{
|
|
112
|
-
code: 'PREREQUISITES_FAILED',
|
|
113
|
+
code: resolution.errorCode ?? 'PREREQUISITES_FAILED',
|
|
113
114
|
message: resolution.message ?? 'Prerequisites failed',
|
|
114
115
|
}], definition.name);
|
|
115
116
|
}
|
|
@@ -20,6 +20,8 @@ const SALT_BYTES = 32;
|
|
|
20
20
|
const KEY_BYTES = 32;
|
|
21
21
|
const PBKDF2_ITERATIONS = 100_000;
|
|
22
22
|
const PBKDF2_DIGEST = 'sha512';
|
|
23
|
+
/** User-typeable floor for any passphrase that protects the store. */
|
|
24
|
+
export const MIN_PASSPHRASE_LENGTH = 8;
|
|
23
25
|
// ============================================================================
|
|
24
26
|
// Encryption Helpers
|
|
25
27
|
// ============================================================================
|
|
@@ -64,8 +66,8 @@ export class CredentialStore {
|
|
|
64
66
|
* Derives the encryption key and loads existing data.
|
|
65
67
|
*/
|
|
66
68
|
unlock(passphrase) {
|
|
67
|
-
if (passphrase.length <
|
|
68
|
-
throw new CredentialStoreError(
|
|
69
|
+
if (passphrase.length < MIN_PASSPHRASE_LENGTH) {
|
|
70
|
+
throw new CredentialStoreError(`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`, 'WEAK_PASSPHRASE');
|
|
69
71
|
}
|
|
70
72
|
this.data = this.readFile();
|
|
71
73
|
const salt = Buffer.from(this.data.salt, 'hex');
|
|
@@ -132,6 +134,20 @@ export class CredentialStore {
|
|
|
132
134
|
this.writeFile(this.data);
|
|
133
135
|
return true;
|
|
134
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Remove every credential in a single write — preferred over a delete loop
|
|
139
|
+
* because each `delete()` rewrites the entire encrypted file.
|
|
140
|
+
* Returns the number of credentials removed.
|
|
141
|
+
*/
|
|
142
|
+
async clear() {
|
|
143
|
+
this.ensureUnlocked();
|
|
144
|
+
const count = Object.keys(this.data.credentials).length;
|
|
145
|
+
if (count === 0)
|
|
146
|
+
return 0;
|
|
147
|
+
this.data.credentials = {};
|
|
148
|
+
this.writeFile(this.data);
|
|
149
|
+
return count;
|
|
150
|
+
}
|
|
135
151
|
/**
|
|
136
152
|
* List credential metadata (names + descriptions, never values).
|
|
137
153
|
*/
|
|
@@ -168,8 +184,8 @@ export class CredentialStore {
|
|
|
168
184
|
* and derived key, re-encrypts everything, and writes atomically.
|
|
169
185
|
*/
|
|
170
186
|
async rotate(oldPassphrase, newPassphrase) {
|
|
171
|
-
if (newPassphrase.length <
|
|
172
|
-
throw new CredentialStoreError(
|
|
187
|
+
if (newPassphrase.length < MIN_PASSPHRASE_LENGTH) {
|
|
188
|
+
throw new CredentialStoreError(`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`, 'WEAK_PASSPHRASE');
|
|
173
189
|
}
|
|
174
190
|
// Verify old passphrase by unlocking with it
|
|
175
191
|
const fileData = this.readFile();
|