moflo 4.9.17 → 4.9.19

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.
@@ -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')} - Browse grimoire templates`,
613
- `${output.highlight('schedule')} - Manage scheduled spells`,
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.push(` - ${f.name}: ${f.installHint}`);
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. On a TTY, prompts the user for unmet env-type prereqs
162
- * whose spec opted into `promptOnMissing`, writes answers into process.env,
163
- * then re-checks. Non-TTY calls and non-promptable unmet prereqs short-circuit
164
- * to a single formatted failure report.
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 unmet = prerequisites.filter((_, i) => !initial[i].satisfied);
175
- if (unmet.length === 0) {
183
+ const unmetIndices = initial.flatMap((r, i) => r.satisfied ? [] : [i]);
184
+ if (unmetIndices.length === 0) {
176
185
  return { ok: true, resolvedNames: [] };
177
186
  }
178
- const promptable = unmet.filter(p => interactive && p.promptOnMissing === true && typeof p.envKey === 'string');
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, unmet.length);
229
+ printPreflightBanner(log, stillUnmet.length);
187
230
  const promptLine = options.promptLine ?? readLineFromStdin;
188
- const resolvedNames = [];
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
- resolvedNames.push(prereq.name);
277
+ promptedNames.push(prereq.name);
226
278
  }
227
279
  }
228
280
  finally {
229
281
  lock.release();
230
282
  }
231
- // Re-check everything any still unmet (e.g. command/file prereqs that
232
- // couldn't be resolved via prompt) fail now with the up-to-date report.
233
- const rerun = await checkPrerequisites(prerequisites);
234
- const stillUnmet = rerun.filter(r => !r.satisfied);
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(rerun),
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 sources,
103
- // prompts on a TTY for unmet env-type prereqs (issue #460).
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 < 8) {
68
- throw new CredentialStoreError('Passphrase must be at least 8 characters', 'WEAK_PASSPHRASE');
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 < 8) {
172
- throw new CredentialStoreError('Passphrase must be at least 8 characters', 'WEAK_PASSPHRASE');
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();