moflo 4.9.28 → 4.9.30
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/dist/src/cli/commands/spell.js +60 -0
- package/dist/src/cli/mcp-tools/spell-tools.js +30 -6
- package/dist/src/cli/spells/core/credential-validation.js +89 -0
- package/dist/src/cli/spells/core/prerequisite-checker.js +75 -16
- package/dist/src/cli/spells/core/runner.js +1 -0
- package/dist/src/cli/spells/core/spell-tier.js +23 -0
- package/dist/src/cli/spells/core/yaml-defaults-writer.js +169 -0
- package/dist/src/cli/spells/factory/runner-bridge.js +2 -0
- package/dist/src/cli/swarm/message-bus/write-through-adapter.js +10 -9
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -15,6 +15,9 @@ import { formatStatus, handleMCPError } from '../services/cli-formatters.js';
|
|
|
15
15
|
import { scheduleCommand } from './spell-schedule.js';
|
|
16
16
|
import { spellCredentialsCommand } from './spell-credentials.js';
|
|
17
17
|
import { loadSpellEngine } from '../services/engine-loader.js';
|
|
18
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { basename } from 'node:path';
|
|
20
|
+
import { updateYamlArgDefaults } from '../spells/core/yaml-defaults-writer.js';
|
|
18
21
|
// Re-export formatStatus as formatStageStatus for table column references
|
|
19
22
|
const formatStageStatus = formatStatus;
|
|
20
23
|
// Shared table column definitions
|
|
@@ -141,6 +144,12 @@ export const castCommand = {
|
|
|
141
144
|
description: 'Single key=value spell argument (repeatable). Numeric/boolean strings are coerced.',
|
|
142
145
|
type: 'array',
|
|
143
146
|
},
|
|
147
|
+
{
|
|
148
|
+
name: 'reprompt-creds',
|
|
149
|
+
description: 'Force re-prompting for env-type prereqs (rotate or correct stored credentials)',
|
|
150
|
+
type: 'boolean',
|
|
151
|
+
default: false,
|
|
152
|
+
},
|
|
144
153
|
],
|
|
145
154
|
examples: [
|
|
146
155
|
{ command: 'moflo spell cast -n development', description: 'Cast spell by name' },
|
|
@@ -148,6 +157,7 @@ export const castCommand = {
|
|
|
148
157
|
{ command: 'moflo spell cast -n sa --dry-run', description: 'Validate via abbreviation' },
|
|
149
158
|
{ command: "moflo spell cast -n oap --args '{\"maxEmails\":50}'", description: 'Pass spell arguments as JSON' },
|
|
150
159
|
{ command: 'moflo spell cast -n oap --arg headless=false --arg maxEmails=50', description: 'Pass arguments as key=value pairs' },
|
|
160
|
+
{ command: 'moflo spell cast -n oap --reprompt-creds', description: 'Re-prompt for credentials (rotate stored secrets)' },
|
|
151
161
|
],
|
|
152
162
|
action: async (ctx) => {
|
|
153
163
|
const name = ctx.flags.name || ctx.args[0];
|
|
@@ -217,6 +227,7 @@ export const castCommand = {
|
|
|
217
227
|
file: file || undefined,
|
|
218
228
|
args,
|
|
219
229
|
dryRun,
|
|
230
|
+
forceCredentialReprompt: ctx.flags.repromptCreds,
|
|
220
231
|
});
|
|
221
232
|
if (result.error) {
|
|
222
233
|
spinner.fail(`Spell failed: ${result.error}`);
|
|
@@ -245,6 +256,22 @@ export const castCommand = {
|
|
|
245
256
|
output.printTable({ columns: STEP_COLUMNS, data: result.steps });
|
|
246
257
|
}
|
|
247
258
|
printSpellErrors(result.errors);
|
|
259
|
+
// #1003 — offer to persist --arg values back into the spell YAML.
|
|
260
|
+
// CLI-interactive only; MCP/headless callers skip by design.
|
|
261
|
+
if (result.success
|
|
262
|
+
&& !dryRun
|
|
263
|
+
&& ctx.interactive
|
|
264
|
+
&& Object.keys(args).length > 0
|
|
265
|
+
&& result.sourceFile) {
|
|
266
|
+
if (result.tier === 'shipped') {
|
|
267
|
+
const hint = name || basename(result.sourceFile).replace(/\.[^.]+$/, '');
|
|
268
|
+
output.writeln();
|
|
269
|
+
output.printInfo(`Shipped spell — copy to spells/dev/${hint}.yaml first to persist defaults.`);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
await offerArgWriteback(result.sourceFile, args);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
248
275
|
return { success: result.success, data: result };
|
|
249
276
|
}
|
|
250
277
|
catch (error) {
|
|
@@ -256,6 +283,39 @@ export const castCommand = {
|
|
|
256
283
|
}
|
|
257
284
|
},
|
|
258
285
|
};
|
|
286
|
+
async function offerArgWriteback(sourceFile, userArgs) {
|
|
287
|
+
const keys = Object.keys(userArgs);
|
|
288
|
+
output.writeln();
|
|
289
|
+
const noun = keys.length === 1 ? 'argument' : 'arguments';
|
|
290
|
+
const accepted = await confirm({
|
|
291
|
+
message: `Save ${keys.length} ${noun} (${keys.join(', ')}) as the spell's defaults?`,
|
|
292
|
+
default: false,
|
|
293
|
+
});
|
|
294
|
+
if (!accepted)
|
|
295
|
+
return;
|
|
296
|
+
let original;
|
|
297
|
+
try {
|
|
298
|
+
original = await readFile(sourceFile, 'utf-8');
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
output.printError(`Could not read spell YAML: ${err.message}`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const result = updateYamlArgDefaults(original, userArgs);
|
|
305
|
+
if (result.updated.length > 0) {
|
|
306
|
+
try {
|
|
307
|
+
await writeFile(sourceFile, result.content, 'utf-8');
|
|
308
|
+
output.printSuccess(`Saved ${result.updated.length} default${result.updated.length === 1 ? '' : 's'}: ${result.updated.join(', ')}`);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
output.printError(`Could not write spell YAML: ${err.message}`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (result.skipped.length > 0) {
|
|
316
|
+
output.printWarning(`Skipped ${result.skipped.length} (not declared in arguments:): ${result.skipped.join(', ')}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
259
319
|
// Validate subcommand
|
|
260
320
|
const validateCommand = {
|
|
261
321
|
name: 'validate',
|
|
@@ -11,6 +11,7 @@ import { loadSpellEngine, getCachedEngine, } from '../services/engine-loader.js'
|
|
|
11
11
|
import { findProjectRoot } from '../services/project-root.js';
|
|
12
12
|
import { buildGrimoire } from '../services/grimoire-builder.js';
|
|
13
13
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
14
|
+
import { inferSpellTier } from '../spells/core/spell-tier.js';
|
|
14
15
|
// ============================================================================
|
|
15
16
|
// Constants
|
|
16
17
|
// ============================================================================
|
|
@@ -53,14 +54,18 @@ function trackResult(tracked, result) {
|
|
|
53
54
|
tracked.completedAt = new Date().toISOString();
|
|
54
55
|
}
|
|
55
56
|
/** Execute a definition via the engine with tracking and error handling. */
|
|
56
|
-
async function executeAndTrack(engine, definition, args) {
|
|
57
|
+
async function executeAndTrack(engine, definition, args, options = {}) {
|
|
57
58
|
const spellId = `sp-${Date.now()}`;
|
|
58
59
|
const tracked = trackStart(spellId, definition.name, definition.description);
|
|
59
60
|
try {
|
|
60
61
|
const sandboxConfig = await engine.loadSandboxConfigFromProject(findProjectRoot());
|
|
61
|
-
const result = await engine.bridgeExecuteSpell(definition, args, {
|
|
62
|
+
const result = await engine.bridgeExecuteSpell(definition, args, {
|
|
63
|
+
spellId,
|
|
64
|
+
sandboxConfig,
|
|
65
|
+
forceCredentialReprompt: options.forceCredentialReprompt,
|
|
66
|
+
});
|
|
62
67
|
trackResult(tracked, result);
|
|
63
|
-
return serializeResult(result);
|
|
68
|
+
return withSpellSource(serializeResult(result), options.sourceFile, options.tier);
|
|
64
69
|
}
|
|
65
70
|
catch (err) {
|
|
66
71
|
tracked.status = SPELL_STATUS.FAILED;
|
|
@@ -68,6 +73,14 @@ async function executeAndTrack(engine, definition, args) {
|
|
|
68
73
|
return { spellId, error: errorDetail(err) };
|
|
69
74
|
}
|
|
70
75
|
}
|
|
76
|
+
/** Attach sourceFile + tier metadata to a serialized cast response. (#1003) */
|
|
77
|
+
function withSpellSource(base, sourceFile, tier) {
|
|
78
|
+
if (sourceFile)
|
|
79
|
+
base.sourceFile = sourceFile;
|
|
80
|
+
if (tier)
|
|
81
|
+
base.tier = tier;
|
|
82
|
+
return base;
|
|
83
|
+
}
|
|
71
84
|
// ============================================================================
|
|
72
85
|
// Registry singleton (created once per session, refreshable on demand)
|
|
73
86
|
// ============================================================================
|
|
@@ -149,11 +162,16 @@ export const spellTools = [
|
|
|
149
162
|
content: { type: 'string', description: 'Inline YAML/JSON spell incantation' },
|
|
150
163
|
args: { type: 'object', description: 'Reagents (arguments) to pass to the spell' },
|
|
151
164
|
dryRun: { type: 'boolean', description: 'Preview the spell without casting' },
|
|
165
|
+
forceCredentialReprompt: {
|
|
166
|
+
type: 'boolean',
|
|
167
|
+
description: 'Force re-prompting for env-type prereqs even when the credential store has a stored value (used to rotate or correct stored secrets)',
|
|
168
|
+
},
|
|
152
169
|
},
|
|
153
170
|
},
|
|
154
171
|
handler: async (input) => {
|
|
155
172
|
const args = input.args ?? {};
|
|
156
173
|
const dryRun = input.dryRun;
|
|
174
|
+
const forceCredentialReprompt = input.forceCredentialReprompt;
|
|
157
175
|
if (input.name) {
|
|
158
176
|
// Resolve via registry and execute the parsed definition directly
|
|
159
177
|
const registry = await getRegistry();
|
|
@@ -162,7 +180,11 @@ export const spellTools = [
|
|
|
162
180
|
return { error: `Spell not found in grimoire: ${input.name}` };
|
|
163
181
|
}
|
|
164
182
|
const engine = await loadSpellEngine();
|
|
165
|
-
return executeAndTrack(engine, loaded.definition, args
|
|
183
|
+
return executeAndTrack(engine, loaded.definition, args, {
|
|
184
|
+
forceCredentialReprompt,
|
|
185
|
+
sourceFile: loaded.sourceFile,
|
|
186
|
+
tier: loaded.tier,
|
|
187
|
+
});
|
|
166
188
|
}
|
|
167
189
|
// Determine raw content source
|
|
168
190
|
let content;
|
|
@@ -189,10 +211,12 @@ export const spellTools = [
|
|
|
189
211
|
// Run from raw content via bridge
|
|
190
212
|
const engine = await loadSpellEngine();
|
|
191
213
|
const sandboxConfig = await engine.loadSandboxConfigFromProject(findProjectRoot());
|
|
192
|
-
const result = await engine.bridgeRunSpell(content, sourceFile, args, {
|
|
214
|
+
const result = await engine.bridgeRunSpell(content, sourceFile, args, {
|
|
215
|
+
dryRun, sandboxConfig, forceCredentialReprompt,
|
|
216
|
+
});
|
|
193
217
|
const tracked = trackStart(result.spellId, spellName);
|
|
194
218
|
trackResult(tracked, result);
|
|
195
|
-
return serializeResult(result);
|
|
219
|
+
return withSpellSource(serializeResult(result), sourceFile, sourceFile ? inferSpellTier(sourceFile) : undefined);
|
|
196
220
|
},
|
|
197
221
|
},
|
|
198
222
|
// --------------------------------------------------------------------------
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential Validation
|
|
3
|
+
*
|
|
4
|
+
* Lightweight, no-config shape checks applied to values pulled from the
|
|
5
|
+
* encrypted credential store before they are promoted to `process.env`.
|
|
6
|
+
*
|
|
7
|
+
* Two heuristics, both conservative — only invalidate when there is
|
|
8
|
+
* positive evidence the stored value is bad. Anything we can't classify
|
|
9
|
+
* passes through unchanged.
|
|
10
|
+
*
|
|
11
|
+
* - JWT-shaped values (3 base64url segments) get their `exp` claim
|
|
12
|
+
* parsed and compared to "now". An expired JWT is reported as such.
|
|
13
|
+
* - Env keys ending in `_URL` must parse via the WHATWG `URL`
|
|
14
|
+
* constructor and have a non-empty host.
|
|
15
|
+
*
|
|
16
|
+
* Story #1007: avoid silently reusing stale stored credentials (e.g.
|
|
17
|
+
* Microsoft Graph access tokens, which expire in ~1h) so the resolver
|
|
18
|
+
* can fall through to the prompt path and the user understands why.
|
|
19
|
+
*/
|
|
20
|
+
const VALID_JWT_SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
21
|
+
export function validateStoredCredential(envKey, value) {
|
|
22
|
+
if (envKey.endsWith('_URL')) {
|
|
23
|
+
return validateUrlValue(value);
|
|
24
|
+
}
|
|
25
|
+
if (looksLikeJwt(value)) {
|
|
26
|
+
return validateJwtExpiry(value);
|
|
27
|
+
}
|
|
28
|
+
return { valid: true };
|
|
29
|
+
}
|
|
30
|
+
function validateUrlValue(value) {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = new URL(value);
|
|
33
|
+
if (!parsed.host) {
|
|
34
|
+
return { valid: false, reason: 'stored value is not a valid URL (missing host)' };
|
|
35
|
+
}
|
|
36
|
+
return { valid: true };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return { valid: false, reason: 'stored value is not a valid URL' };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function looksLikeJwt(value) {
|
|
43
|
+
const parts = value.split('.');
|
|
44
|
+
if (parts.length !== 3)
|
|
45
|
+
return false;
|
|
46
|
+
return parts.every(p => p.length > 0 && VALID_JWT_SEGMENT.test(p));
|
|
47
|
+
}
|
|
48
|
+
function validateJwtExpiry(value) {
|
|
49
|
+
const exp = readJwtExp(value);
|
|
50
|
+
if (exp == null)
|
|
51
|
+
return { valid: true };
|
|
52
|
+
const expiryMs = exp * 1000;
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
if (expiryMs >= now)
|
|
55
|
+
return { valid: true };
|
|
56
|
+
return { valid: false, reason: `JWT expired ${formatDuration(now - expiryMs)} ago` };
|
|
57
|
+
}
|
|
58
|
+
function readJwtExp(value) {
|
|
59
|
+
try {
|
|
60
|
+
const payload = value.split('.')[1];
|
|
61
|
+
const padLen = (4 - (payload.length % 4)) % 4;
|
|
62
|
+
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(padLen);
|
|
63
|
+
const decoded = Buffer.from(b64, 'base64').toString('utf-8');
|
|
64
|
+
const parsed = JSON.parse(decoded);
|
|
65
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
66
|
+
const exp = parsed.exp;
|
|
67
|
+
if (typeof exp === 'number' && Number.isFinite(exp))
|
|
68
|
+
return exp;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function formatDuration(ms) {
|
|
77
|
+
const s = Math.floor(ms / 1000);
|
|
78
|
+
if (s < 60)
|
|
79
|
+
return `${s}s`;
|
|
80
|
+
const m = Math.floor(s / 60);
|
|
81
|
+
if (m < 60)
|
|
82
|
+
return `${m}m`;
|
|
83
|
+
const h = Math.floor(m / 60);
|
|
84
|
+
if (h < 24)
|
|
85
|
+
return `${h}h ${m % 60}m`;
|
|
86
|
+
const d = Math.floor(h / 24);
|
|
87
|
+
return `${d}d ${h % 24}h`;
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=credential-validation.js.map
|
|
@@ -18,6 +18,7 @@ import { access } from 'node:fs/promises';
|
|
|
18
18
|
import { promisify } from 'node:util';
|
|
19
19
|
import { acquireTTYLock } from './tty-lock.js';
|
|
20
20
|
import { readLineFromStdin } from './stdin-reader.js';
|
|
21
|
+
import { validateStoredCredential } from './credential-validation.js';
|
|
21
22
|
const execFileAsync = promisify(execFile);
|
|
22
23
|
/**
|
|
23
24
|
* Check whether a CLI command is available on the system PATH.
|
|
@@ -165,7 +166,9 @@ function appendPrereqLine(lines, name, hint, url) {
|
|
|
165
166
|
* Evaluate all prereqs. Resolution chain for env-type prereqs:
|
|
166
167
|
* 1. process.env[key] already set → satisfied.
|
|
167
168
|
* 2. credentials.get(key) returns a value → write to process.env, satisfied.
|
|
168
|
-
* 3. TTY interactive → prompt → write to process.env
|
|
169
|
+
* 3. TTY interactive → prompt → write to process.env. After ALL prompts,
|
|
170
|
+
* one Y/n offer asks whether to persist the collected answers to the
|
|
171
|
+
* credential store as a batch (default Y). Story #1002.
|
|
169
172
|
* 4. Non-TTY or no credentials → fail fast with `errorCode: 'MISSING_CREDENTIAL'`.
|
|
170
173
|
*
|
|
171
174
|
* Non-env prereqs (`command`, `file`) bypass the credential chain and surface
|
|
@@ -186,19 +189,30 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
186
189
|
}
|
|
187
190
|
// Pull env-type prereqs from the store in parallel — each resolved index
|
|
188
191
|
// lets us skip a re-check of the cheap env detector.
|
|
192
|
+
// `forceCredentialReprompt` bypasses this step so users can rotate or
|
|
193
|
+
// correct stored credentials by re-running through the prompt path.
|
|
194
|
+
// Stored values are shape-validated (#1007): expired JWTs and malformed
|
|
195
|
+
// _URL values are rejected and surface in the preflight banner, so the
|
|
196
|
+
// resolver re-prompts instead of silently feeding garbage to the spell.
|
|
189
197
|
const resolvedFromStoreNames = [];
|
|
190
198
|
const storeResolved = new Set();
|
|
191
|
-
|
|
199
|
+
const rejectedFromStore = [];
|
|
200
|
+
if (credentials && !options.forceCredentialReprompt) {
|
|
192
201
|
await Promise.all(unmetIndices.map(async (i) => {
|
|
193
202
|
const prereq = prerequisites[i];
|
|
194
203
|
if (!prereq.envKey)
|
|
195
204
|
return;
|
|
196
205
|
const stored = await credentials.get(prereq.envKey);
|
|
197
|
-
if (typeof stored
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
206
|
+
if (typeof stored !== 'string' || stored.length === 0)
|
|
207
|
+
return;
|
|
208
|
+
const validation = validateStoredCredential(prereq.envKey, stored);
|
|
209
|
+
if (!validation.valid) {
|
|
210
|
+
rejectedFromStore.push({ envKey: prereq.envKey, reason: validation.reason });
|
|
211
|
+
return;
|
|
201
212
|
}
|
|
213
|
+
process.env[prereq.envKey] = stored;
|
|
214
|
+
storeResolved.add(i);
|
|
215
|
+
resolvedFromStoreNames.push(prereq.name);
|
|
202
216
|
}));
|
|
203
217
|
}
|
|
204
218
|
const stillUnmetIdx = unmetIndices.filter(i => !storeResolved.has(i));
|
|
@@ -214,7 +228,7 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
214
228
|
if (promptableEnvKeys.length > 0) {
|
|
215
229
|
return {
|
|
216
230
|
ok: false,
|
|
217
|
-
message: formatMissingCredentialMessage(promptableEnvKeys, stillUnmet),
|
|
231
|
+
message: formatMissingCredentialMessage(promptableEnvKeys, stillUnmet, rejectedFromStore),
|
|
218
232
|
resolvedNames: resolvedFromStoreNames,
|
|
219
233
|
errorCode: 'MISSING_CREDENTIAL',
|
|
220
234
|
missingCredentials: promptableEnvKeys,
|
|
@@ -227,10 +241,18 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
227
241
|
};
|
|
228
242
|
}
|
|
229
243
|
printPreflightBanner(log, stillUnmet.length);
|
|
244
|
+
if (rejectedFromStore.length > 0) {
|
|
245
|
+
for (const r of rejectedFromStore) {
|
|
246
|
+
log(` Stored ${r.envKey} rejected — ${r.reason}. Re-enter below.`);
|
|
247
|
+
}
|
|
248
|
+
log('');
|
|
249
|
+
}
|
|
230
250
|
const promptLine = options.promptLine ?? readLineFromStdin;
|
|
231
251
|
const promptedNames = [];
|
|
232
252
|
const promptableSet = new Set(promptable);
|
|
253
|
+
const pendingSaves = [];
|
|
233
254
|
const lock = acquireTTYLock();
|
|
255
|
+
let shouldSave = false;
|
|
234
256
|
try {
|
|
235
257
|
for (const prereq of promptable) {
|
|
236
258
|
if (options.abortSignal?.aborted) {
|
|
@@ -265,21 +287,32 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
265
287
|
}
|
|
266
288
|
if (prereq.envKey) {
|
|
267
289
|
process.env[prereq.envKey] = answer;
|
|
268
|
-
if (credentials)
|
|
269
|
-
|
|
270
|
-
await credentials.store(prereq.envKey, answer);
|
|
271
|
-
}
|
|
272
|
-
catch (err) {
|
|
273
|
-
log(` (could not persist credential "${prereq.envKey}": ${err.message})`);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
290
|
+
if (credentials)
|
|
291
|
+
pendingSaves.push({ envKey: prereq.envKey, value: answer });
|
|
276
292
|
}
|
|
277
293
|
promptedNames.push(prereq.name);
|
|
278
294
|
}
|
|
295
|
+
// One batched save offer covers every collected answer — issuing N
|
|
296
|
+
// confirmations would be repetitive when most users want all-or-nothing.
|
|
297
|
+
if (credentials && pendingSaves.length > 0) {
|
|
298
|
+
shouldSave = await promptSaveCredentialsConfirmation(promptLine, pendingSaves.length, options.abortSignal);
|
|
299
|
+
}
|
|
279
300
|
}
|
|
280
301
|
finally {
|
|
281
302
|
lock.release();
|
|
282
303
|
}
|
|
304
|
+
// Persist outside the TTY lock so slow disk/keychain writes don't block
|
|
305
|
+
// concurrent stdin readers waiting on the same lock.
|
|
306
|
+
if (credentials && shouldSave) {
|
|
307
|
+
for (const { envKey, value } of pendingSaves) {
|
|
308
|
+
try {
|
|
309
|
+
await credentials.store(envKey, value);
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
log(` (could not persist credential "${envKey}": ${err.message})`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
283
316
|
// Anything in stillUnmet that wasn't promptable (typically command/file
|
|
284
317
|
// prereqs) is still broken — carry forward the initial detector result.
|
|
285
318
|
const unfixableIdx = stillUnmetIdx.filter(i => !promptableSet.has(prerequisites[i]));
|
|
@@ -292,18 +325,44 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
292
325
|
}
|
|
293
326
|
return { ok: true, resolvedNames: [...resolvedFromStoreNames, ...promptedNames] };
|
|
294
327
|
}
|
|
295
|
-
function formatMissingCredentialMessage(envKeys, prereqs) {
|
|
328
|
+
function formatMissingCredentialMessage(envKeys, prereqs, rejectedFromStore) {
|
|
296
329
|
const lines = ['Missing credentials (cannot prompt — non-interactive run):'];
|
|
297
330
|
for (const key of envKeys) {
|
|
298
331
|
const prereq = prereqs.find(p => p.envKey === key);
|
|
299
332
|
const label = `${prereq?.name ?? key} (${key})`;
|
|
300
333
|
appendPrereqLine(lines, label, prereq?.installHint, prereq?.url);
|
|
301
334
|
}
|
|
335
|
+
if (rejectedFromStore.length > 0) {
|
|
336
|
+
lines.push('');
|
|
337
|
+
lines.push('Stored value(s) rejected as stale or invalid:');
|
|
338
|
+
for (const r of rejectedFromStore) {
|
|
339
|
+
lines.push(` - ${r.envKey}: ${r.reason}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
302
342
|
lines.push('');
|
|
303
343
|
lines.push('Prime these by casting the spell once interactively, or run:');
|
|
304
344
|
lines.push(' flo spell credentials set <name>');
|
|
305
345
|
return lines.join('\n');
|
|
306
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Ask once whether to persist every collected prereq answer to the
|
|
349
|
+
* credential store. Empty input or `y/yes` → true (default Y); `n/no` →
|
|
350
|
+
* false. A failed prompt (e.g. abort) silently declines so the cast
|
|
351
|
+
* still proceeds with the in-memory env values.
|
|
352
|
+
*/
|
|
353
|
+
async function promptSaveCredentialsConfirmation(promptLine, count, abortSignal) {
|
|
354
|
+
const noun = count === 1 ? 'credential' : 'credentials';
|
|
355
|
+
const prompt = ` Save ${count} ${noun} to the encrypted credential store for future runs? [Y/n] `;
|
|
356
|
+
let answer;
|
|
357
|
+
try {
|
|
358
|
+
answer = await promptLine(prompt, abortSignal);
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
const trimmed = answer.trim().toLowerCase();
|
|
364
|
+
return trimmed === '' || trimmed === 'y' || trimmed === 'yes';
|
|
365
|
+
}
|
|
307
366
|
function printPreflightBanner(log, unmetCount) {
|
|
308
367
|
log('');
|
|
309
368
|
log('\x1b[1;36m━━━ Preflight: missing prerequisites ━━━\x1b[0m');
|
|
@@ -107,6 +107,7 @@ export class SpellCaster {
|
|
|
107
107
|
const resolution = await resolveUnmetPrerequisites(prerequisites, {
|
|
108
108
|
abortSignal: options.signal,
|
|
109
109
|
credentials: this.credentials,
|
|
110
|
+
forceCredentialReprompt: options.forceCredentialReprompt,
|
|
110
111
|
});
|
|
111
112
|
if (!resolution.ok) {
|
|
112
113
|
return this.failureResult(spellId, startTime, [{
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a spell's source path as 'shipped' (ships with moflo, read-only
|
|
3
|
+
* from the consumer's perspective) or 'user' (project-local, writable).
|
|
4
|
+
*
|
|
5
|
+
* Story #1003 — used by the post-cast arg-writeback offer to skip the save
|
|
6
|
+
* prompt for shipped YAMLs (mutating them inside `node_modules/moflo/...`
|
|
7
|
+
* would be wiped on the next `npm install` and confuses provenance).
|
|
8
|
+
*/
|
|
9
|
+
const SHIPPED_MARKERS = [
|
|
10
|
+
// Installed package — both bare 'moflo' and the legacy '@moflo/*' workspace.
|
|
11
|
+
/\/node_modules\/moflo\//,
|
|
12
|
+
/\/node_modules\/@moflo\//,
|
|
13
|
+
// Source-tree shipped definitions (relevant when moflo dogfoods itself).
|
|
14
|
+
/\/src\/cli\/spells\/definitions\//,
|
|
15
|
+
/\/dist\/[^/]*\/?cli\/spells\/definitions\//,
|
|
16
|
+
];
|
|
17
|
+
export function inferSpellTier(sourceFile) {
|
|
18
|
+
if (!sourceFile)
|
|
19
|
+
return 'user';
|
|
20
|
+
const norm = sourceFile.replace(/\\/g, '/');
|
|
21
|
+
return SHIPPED_MARKERS.some((re) => re.test(norm)) ? 'shipped' : 'user';
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=spell-tier.js.map
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// YAML line-editor: rewrite `arguments.<key>.default` without a full
|
|
2
|
+
// round-trip through js-yaml (which would drop comments). Anchors and
|
|
3
|
+
// block-scalar defaults are listed in `skipped` so callers can surface them.
|
|
4
|
+
import * as yaml from 'js-yaml';
|
|
5
|
+
const ARGUMENTS_HEADER_RE = /^arguments\s*:\s*(?:#.*)?$/;
|
|
6
|
+
const KEY_LINE_RE = /^(\s*)([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/;
|
|
7
|
+
const DEFAULT_LINE_RE = /^(\s*default\s*:\s*)(?:[^#\n]*?)(\s*#.*)?$/;
|
|
8
|
+
const BLOCK_SCALAR_DEFAULT_RE = /^\s*default\s*:\s*[|>][+-]?\d*\s*(?:#.*)?$/;
|
|
9
|
+
export function updateYamlArgDefaults(yamlContent, updates) {
|
|
10
|
+
const updateKeys = Object.keys(updates);
|
|
11
|
+
if (updateKeys.length === 0) {
|
|
12
|
+
return { content: yamlContent, updated: [], skipped: [] };
|
|
13
|
+
}
|
|
14
|
+
const usesCRLF = /\r\n/.test(yamlContent);
|
|
15
|
+
const normalized = usesCRLF ? yamlContent.replace(/\r\n/g, '\n') : yamlContent;
|
|
16
|
+
const lines = normalized.split('\n');
|
|
17
|
+
const argumentsLineIdx = findArgumentsHeader(lines);
|
|
18
|
+
if (argumentsLineIdx === -1) {
|
|
19
|
+
return { content: yamlContent, updated: [], skipped: updateKeys };
|
|
20
|
+
}
|
|
21
|
+
const argKeyIndent = findArgKeyIndent(lines, argumentsLineIdx);
|
|
22
|
+
if (argKeyIndent === -1) {
|
|
23
|
+
return { content: yamlContent, updated: [], skipped: updateKeys };
|
|
24
|
+
}
|
|
25
|
+
const remaining = new Set(updateKeys);
|
|
26
|
+
const updated = [];
|
|
27
|
+
const skippedExtra = [];
|
|
28
|
+
let i = argumentsLineIdx + 1;
|
|
29
|
+
while (i < lines.length) {
|
|
30
|
+
const line = lines[i];
|
|
31
|
+
const stripped = line.trim();
|
|
32
|
+
if (!stripped || stripped.startsWith('#')) {
|
|
33
|
+
i++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const indent = leadingSpaces(line);
|
|
37
|
+
if (indent === 0)
|
|
38
|
+
break;
|
|
39
|
+
if (indent !== argKeyIndent) {
|
|
40
|
+
i++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const m = KEY_LINE_RE.exec(line);
|
|
44
|
+
if (!m) {
|
|
45
|
+
i++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const argName = m[2];
|
|
49
|
+
if (!remaining.has(argName)) {
|
|
50
|
+
i = skipBlock(lines, i + 1, argKeyIndent);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const blockStart = i + 1;
|
|
54
|
+
const blockEnd = skipBlock(lines, blockStart, argKeyIndent);
|
|
55
|
+
const propIndent = findPropIndent(lines, blockStart, blockEnd, argKeyIndent);
|
|
56
|
+
const defaultLineIdx = findDefaultLine(lines, blockStart, blockEnd, propIndent);
|
|
57
|
+
const newValueStr = formatYamlValue(updates[argName]);
|
|
58
|
+
// Refuse to touch block-scalar defaults — replacing the header line with
|
|
59
|
+
// an inline scalar would orphan the continuation lines as sibling keys.
|
|
60
|
+
if (defaultLineIdx !== -1 && BLOCK_SCALAR_DEFAULT_RE.test(lines[defaultLineIdx])) {
|
|
61
|
+
skippedExtra.push(argName);
|
|
62
|
+
remaining.delete(argName);
|
|
63
|
+
i = skipBlock(lines, i + 1, argKeyIndent);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// Refuse multi-line dumped values for the same orphan-line reason.
|
|
67
|
+
if (newValueStr.includes('\n')) {
|
|
68
|
+
skippedExtra.push(argName);
|
|
69
|
+
remaining.delete(argName);
|
|
70
|
+
i = skipBlock(lines, i + 1, argKeyIndent);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (defaultLineIdx !== -1) {
|
|
74
|
+
lines[defaultLineIdx] = replaceDefaultValue(lines[defaultLineIdx], newValueStr);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const insertIndent = ' '.repeat(propIndent);
|
|
78
|
+
lines.splice(blockStart, 0, `${insertIndent}default: ${newValueStr}`);
|
|
79
|
+
}
|
|
80
|
+
updated.push(argName);
|
|
81
|
+
remaining.delete(argName);
|
|
82
|
+
i = skipBlock(lines, i + 1, argKeyIndent);
|
|
83
|
+
}
|
|
84
|
+
const joined = lines.join('\n');
|
|
85
|
+
const content = usesCRLF ? joined.replace(/\n/g, '\r\n') : joined;
|
|
86
|
+
return {
|
|
87
|
+
content,
|
|
88
|
+
updated,
|
|
89
|
+
skipped: [...remaining, ...skippedExtra],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function findArgumentsHeader(lines) {
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
if (leadingSpaces(lines[i]) === 0 && ARGUMENTS_HEADER_RE.test(lines[i].trim())) {
|
|
95
|
+
return i;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return -1;
|
|
99
|
+
}
|
|
100
|
+
function findArgKeyIndent(lines, headerIdx) {
|
|
101
|
+
for (let i = headerIdx + 1; i < lines.length; i++) {
|
|
102
|
+
const stripped = lines[i].trim();
|
|
103
|
+
if (!stripped || stripped.startsWith('#'))
|
|
104
|
+
continue;
|
|
105
|
+
const indent = leadingSpaces(lines[i]);
|
|
106
|
+
if (indent === 0)
|
|
107
|
+
return -1;
|
|
108
|
+
return indent;
|
|
109
|
+
}
|
|
110
|
+
return -1;
|
|
111
|
+
}
|
|
112
|
+
function findPropIndent(lines, blockStart, blockEnd, argKeyIndent) {
|
|
113
|
+
for (let j = blockStart; j < blockEnd; j++) {
|
|
114
|
+
const stripped = lines[j].trim();
|
|
115
|
+
if (!stripped || stripped.startsWith('#'))
|
|
116
|
+
continue;
|
|
117
|
+
return leadingSpaces(lines[j]);
|
|
118
|
+
}
|
|
119
|
+
return argKeyIndent + 2;
|
|
120
|
+
}
|
|
121
|
+
function findDefaultLine(lines, blockStart, blockEnd, propIndent) {
|
|
122
|
+
for (let j = blockStart; j < blockEnd; j++) {
|
|
123
|
+
const stripped = lines[j].trim();
|
|
124
|
+
if (!stripped || stripped.startsWith('#'))
|
|
125
|
+
continue;
|
|
126
|
+
if (leadingSpaces(lines[j]) !== propIndent)
|
|
127
|
+
continue;
|
|
128
|
+
if (/^default\s*:/.test(stripped))
|
|
129
|
+
return j;
|
|
130
|
+
}
|
|
131
|
+
return -1;
|
|
132
|
+
}
|
|
133
|
+
function skipBlock(lines, start, parentIndent) {
|
|
134
|
+
for (let i = start; i < lines.length; i++) {
|
|
135
|
+
const stripped = lines[i].trim();
|
|
136
|
+
if (!stripped || stripped.startsWith('#'))
|
|
137
|
+
continue;
|
|
138
|
+
if (leadingSpaces(lines[i]) <= parentIndent)
|
|
139
|
+
return i;
|
|
140
|
+
}
|
|
141
|
+
return lines.length;
|
|
142
|
+
}
|
|
143
|
+
function leadingSpaces(line) {
|
|
144
|
+
let n = 0;
|
|
145
|
+
while (n < line.length && line[n] === ' ')
|
|
146
|
+
n++;
|
|
147
|
+
return n;
|
|
148
|
+
}
|
|
149
|
+
function replaceDefaultValue(line, newValue) {
|
|
150
|
+
const m = DEFAULT_LINE_RE.exec(line);
|
|
151
|
+
if (!m)
|
|
152
|
+
return line;
|
|
153
|
+
const prefix = m[1];
|
|
154
|
+
const trailingComment = m[2] ?? '';
|
|
155
|
+
return `${prefix}${newValue}${trailingComment}`;
|
|
156
|
+
}
|
|
157
|
+
export function formatYamlValue(value) {
|
|
158
|
+
if (value === null || value === undefined)
|
|
159
|
+
return 'null';
|
|
160
|
+
if (typeof value === 'boolean' || typeof value === 'number')
|
|
161
|
+
return String(value);
|
|
162
|
+
const dumped = yaml.dump(value, {
|
|
163
|
+
flowLevel: 0,
|
|
164
|
+
lineWidth: -1,
|
|
165
|
+
noRefs: true,
|
|
166
|
+
}).replace(/\r?\n$/, '');
|
|
167
|
+
return dumped;
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=yaml-defaults-writer.js.map
|
|
@@ -45,6 +45,7 @@ export async function bridgeRunSpell(content, sourceFile, args, options = {}) {
|
|
|
45
45
|
signal: controller.signal,
|
|
46
46
|
memory: options.memory,
|
|
47
47
|
credentials,
|
|
48
|
+
forceCredentialReprompt: options.forceCredentialReprompt,
|
|
48
49
|
...(options.projectRoot ? { projectRoot: options.projectRoot } : {}),
|
|
49
50
|
...(sandboxConfig ? { sandboxConfig } : {}),
|
|
50
51
|
});
|
|
@@ -68,6 +69,7 @@ export async function bridgeExecuteSpell(definition, args, options = {}) {
|
|
|
68
69
|
return await runner.run(definition, args, {
|
|
69
70
|
spellId,
|
|
70
71
|
signal: controller.signal,
|
|
72
|
+
forceCredentialReprompt: options.forceCredentialReprompt,
|
|
71
73
|
...(options.projectRoot ? { projectRoot: options.projectRoot } : {}),
|
|
72
74
|
...(sandboxConfig ? { sandboxConfig } : {}),
|
|
73
75
|
});
|
|
@@ -101,16 +101,17 @@ export class WriteThroughAdapter {
|
|
|
101
101
|
// Best-effort cleanup
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
* own daemon-write-client timeout).
|
|
109
|
-
*/
|
|
104
|
+
// #1003 — yield to the microtask queue and re-check until pendingWrites
|
|
105
|
+
// settles. The single-snapshot pattern lost a race on fast hosts (Ubuntu CI)
|
|
106
|
+
// where a caller's awaited bus.sendUnified resolved before its `.then()`
|
|
107
|
+
// chain registered the write-through promise.
|
|
110
108
|
async drainPendingWrites() {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
for (let i = 0; i < 5; i++) {
|
|
110
|
+
await Promise.resolve();
|
|
111
|
+
if (this.pendingWrites.size === 0)
|
|
112
|
+
return;
|
|
113
|
+
await Promise.allSettled([...this.pendingWrites]);
|
|
114
|
+
}
|
|
114
115
|
}
|
|
115
116
|
onUnifiedMessage(event) {
|
|
116
117
|
if (!event.namespace || !this.enabledNamespaces.has(event.namespace)) {
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.30",
|
|
4
4
|
"description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
|
|
5
5
|
"main": "dist/src/cli/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
85
85
|
"@typescript-eslint/parser": "^7.18.0",
|
|
86
86
|
"eslint": "^8.0.0",
|
|
87
|
-
"moflo": "^4.9.
|
|
87
|
+
"moflo": "^4.9.29",
|
|
88
88
|
"tsx": "^4.21.0",
|
|
89
89
|
"typescript": "^5.9.3",
|
|
90
90
|
"vitest": "^4.0.0"
|