@cspeach/cli 1.1.17 → 1.1.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.
- package/README.md +3 -4
- package/dist/agent/loop.js +143 -25
- package/dist/agent/providers/local-provider.js +4 -1
- package/dist/agent/repair-partial.js +76 -19
- package/dist/agent/tool-dispatch.js +80 -22
- package/dist/agent/turn-error-ux.js +77 -26
- package/dist/agent/turn-stream.js +25 -3
- package/dist/approvals/adt-type.js +27 -0
- package/dist/approvals/advisory-prompt.js +48 -0
- package/dist/approvals/advisory-render.js +25 -11
- package/dist/approvals/approval-prompt.js +74 -24
- package/dist/approvals/jwt.js +2 -1
- package/dist/approvals/render.js +64 -36
- package/dist/approvals/risk-floor.js +53 -2
- package/dist/auth/api-key.js +30 -16
- package/dist/auth/keychain.js +0 -0
- package/dist/auth/me.js +25 -7
- package/dist/cli.js +25 -6
- package/dist/commands/compact.js +12 -9
- package/dist/commands/config-set.js +6 -2
- package/dist/commands/config-show.js +7 -5
- package/dist/commands/cost.js +41 -34
- package/dist/commands/help.js +88 -50
- package/dist/commands/login.js +28 -19
- package/dist/commands/logout.js +7 -3
- package/dist/commands/plan-chain.js +71 -77
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +20 -12
- package/dist/commands/whoami.js +23 -9
- package/dist/config/loader.js +15 -3
- package/dist/cost/pricing.js +6 -5
- package/dist/doctor/checks/forge-rules.js +13 -3
- package/dist/doctor/checks/keychain.js +6 -4
- package/dist/doctor/run.js +101 -20
- package/dist/index.js +5 -2
- package/dist/lib/graceful-exit.js +56 -0
- package/dist/lib/piped-prompt.js +65 -0
- package/dist/lock-contention.js +2 -2
- package/dist/one-shot.js +135 -40
- package/dist/projects/answer-blockers.js +6 -1
- package/dist/projects/handover-md.js +15 -14
- package/dist/projects/plan-run.js +97 -54
- package/dist/projects/promote-command.js +11 -2
- package/dist/projects/save-command.js +3 -2
- package/dist/projects/status.js +2 -1
- package/dist/projects/yes-no.js +12 -0
- package/dist/renderer/abap-inline.js +18 -14
- package/dist/renderer/answer-trim.js +69 -0
- package/dist/renderer/banners.js +8 -8
- package/dist/renderer/brand-settled.js +19 -0
- package/dist/renderer/change-card.js +156 -0
- package/dist/renderer/checklist-format.js +130 -0
- package/dist/renderer/color-mode.js +45 -0
- package/dist/renderer/error-detail.js +104 -0
- package/dist/renderer/fence-state.js +46 -0
- package/dist/renderer/footer-line.js +96 -0
- package/dist/renderer/glyphs.js +25 -0
- package/dist/renderer/goodbye.js +38 -0
- package/dist/renderer/legacy-palette.js +41 -0
- package/dist/renderer/look.js +30 -0
- package/dist/renderer/markdown.js +299 -92
- package/dist/renderer/notice-log.js +72 -0
- package/dist/renderer/notice-shape.js +48 -0
- package/dist/renderer/notices.js +40 -8
- package/dist/renderer/panel-rows.js +16 -0
- package/dist/renderer/pipeline.js +66 -0
- package/dist/renderer/progress-chatter.js +23 -17
- package/dist/renderer/rendering-mode.js +34 -0
- package/dist/renderer/routing-line.js +14 -0
- package/dist/renderer/sanitize.js +12 -0
- package/dist/renderer/severity.js +2 -7
- package/dist/renderer/startup-lines.js +152 -0
- package/dist/renderer/status-footer.js +52 -34
- package/dist/renderer/steering-echo.js +41 -0
- package/dist/renderer/syntax.js +31 -12
- package/dist/renderer/tables.js +5 -1
- package/dist/renderer/theme.js +44 -0
- package/dist/renderer/thinking-heartbeat.js +33 -2
- package/dist/renderer/todo-block.js +10 -49
- package/dist/renderer/tool-labels.js +238 -0
- package/dist/renderer/tool-widget.js +245 -51
- package/dist/renderer/trace.js +42 -0
- package/dist/renderer/transcript-flow.js +132 -0
- package/dist/renderer/tty.js +34 -1
- package/dist/renderer/ui-width.js +42 -0
- package/dist/renderer/verify-chain.js +4 -2
- package/dist/renderer/widget-fallback.js +58 -65
- package/dist/repl/bracketed-paste.js +7 -1
- package/dist/repl/builtin-commands.js +19 -8
- package/dist/repl/credential-handover-gate.js +28 -0
- package/dist/repl/early-line-buffer.js +5 -2
- package/dist/repl/file-picker.js +54 -12
- package/dist/repl/ink-stdin-guard.js +66 -0
- package/dist/repl/inquirer-guard.js +59 -16
- package/dist/repl/inquirer-theme.js +27 -33
- package/dist/repl/paste-marker.js +19 -0
- package/dist/repl/plan-turn-end.js +20 -0
- package/dist/repl/post-turn-status.js +36 -11
- package/dist/repl/reroute-turn.js +21 -0
- package/dist/repl/reset-tty-stdin.js +44 -0
- package/dist/repl/restore-guard.js +22 -0
- package/dist/repl/resume-standalone.js +63 -0
- package/dist/repl/rule8-detector.js +11 -3
- package/dist/repl/safety-confirm.js +161 -93
- package/dist/repl/session-spend-line.js +8 -4
- package/dist/repl/themed-prompts.js +19 -0
- package/dist/repl/ui-look-command.js +75 -0
- package/dist/repl/update-method-preview-hook.js +48 -5
- package/dist/repl.js +795 -332
- package/dist/rewind/cli.js +3 -2
- package/dist/rewind/format.js +14 -9
- package/dist/rewind/restore.js +11 -0
- package/dist/router/classifier.js +47 -4
- package/dist/router/intent-extractor.js +24 -8
- package/dist/router/piped-routing.js +23 -0
- package/dist/router/resume-routing.js +16 -0
- package/dist/router/routing-failure.js +57 -0
- package/dist/sap/first-run-choice.js +1 -1
- package/dist/sap/onboarding.js +3 -2
- package/dist/sap/standalone-onboarding.js +1 -1
- package/dist/sap/system-info.js +4 -2
- package/dist/sap/unreachable.js +28 -0
- package/dist/sap-errors/clean-error-text.js +182 -0
- package/dist/session/interrupt-reason.js +39 -0
- package/dist/session/recap.js +36 -24
- package/dist/session/resume.js +104 -36
- package/dist/session/store.js +69 -27
- package/dist/session/user-prompt.js +21 -0
- package/dist/skill-catalog.js +7 -0
- package/dist/skills/bundled-skills.js +76 -69
- package/dist/skills/signing-public-key.js +1 -1
- package/dist/standards/standards-init.js +1 -1
- package/dist/test-helpers/answer-any.js +46 -0
- package/dist/tools/approval.js +27 -11
- package/dist/tools/ask-question.js +47 -5
- package/dist/tools/filesystem/file-write.js +19 -2
- package/dist/tools/fiori/fe-extend.js +16 -1
- package/dist/tools/fiori/fe-scaffold.js +105 -3
- package/dist/tools/fiori/html-escapes.js +28 -0
- package/dist/tools/fiori/metadata/audit.js +605 -0
- package/dist/tools/fiori/metadata/references.js +104 -0
- package/dist/tools/fiori/metadata/types.js +8 -0
- package/dist/tools/fiori/preview/app-guard.js +215 -0
- package/dist/tools/fiori/preview/env.js +193 -0
- package/dist/tools/fiori/preview/readiness.js +127 -0
- package/dist/tools/fiori/preview/registry.js +412 -0
- package/dist/tools/fiori/preview/start.js +469 -0
- package/dist/tools/fiori/samples/loader.js +20 -5
- package/dist/tools/fiori/scaffold.js +18 -6
- package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
- package/dist/tools/fiori/smoke/app-driver.js +100 -0
- package/dist/tools/fiori/smoke/assertions.js +161 -24
- package/dist/tools/fiori/smoke/audit-columns.js +21 -0
- package/dist/tools/fiori/smoke/batch.js +273 -0
- package/dist/tools/fiori/smoke/draft-safety.js +241 -0
- package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
- package/dist/tools/fiori/smoke/driver.js +171 -4
- package/dist/tools/fiori/smoke/evidence.js +35 -0
- package/dist/tools/fiori/smoke/labels-codes.js +207 -0
- package/dist/tools/fiori/smoke/nav-error.js +26 -0
- package/dist/tools/fiori/smoke/run-smoke.js +155 -12
- package/dist/tools/fiori/tools.js +651 -7
- package/dist/tools/fiori/ui5-version.js +16 -0
- package/dist/tools/index.js +8 -0
- package/dist/tools/local-build.js +6 -0
- package/dist/tools/sap-read.js +16 -1
- package/dist/tools/sap-write.js +122 -10
- package/dist/tools/shell/shell_exec.js +30 -12
- package/dist/tools/syntax-state.js +67 -0
- package/dist/tools/todo.js +23 -18
- package/dist/tools/transport-fit.js +462 -0
- package/dist/tools/transport-resolution.js +3 -1
- package/dist/tools/transport.js +33 -7
- package/dist/tools/verify.js +184 -8
- package/dist/ui/app.js +284 -146
- package/dist/ui/approval-modal.js +41 -15
- package/dist/ui/ask-answer-text.js +17 -0
- package/dist/ui/body.js +25 -6
- package/dist/ui/card-slot.js +98 -0
- package/dist/ui/checklist.js +9 -0
- package/dist/ui/coaching-picker-classic.js +8 -5
- package/dist/ui/command-palette.js +6 -4
- package/dist/ui/confirm-request.js +18 -0
- package/dist/ui/context-grid.js +2 -1
- package/dist/ui/file-palette.js +6 -4
- package/dist/ui/files-card-emitter.js +19 -0
- package/dist/ui/footer-line.js +16 -0
- package/dist/ui/footer.js +105 -104
- package/dist/ui/input-wrap.js +122 -0
- package/dist/ui/interrupt.js +103 -0
- package/dist/ui/key-burst.js +117 -0
- package/dist/ui/line-resolution.js +4 -3
- package/dist/ui/live-area.js +71 -0
- package/dist/ui/login-banner.js +54 -43
- package/dist/ui/rewind-panel.js +76 -24
- package/dist/ui/role-props.js +47 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +49 -3
- package/dist/ui/skill-picker.js +5 -47
- package/dist/ui/text-input.js +128 -24
- package/dist/ui/todo-emitter.js +19 -0
- package/dist/ui/turn-status-emitter.js +28 -2
- package/dist/ui/turn-status.js +16 -19
- package/dist/ui/use-card-request.js +44 -0
- package/dist/ui/widgets/ask-card.js +521 -0
- package/dist/ui/widgets/bar-chart.js +11 -5
- package/dist/ui/widgets/coaching-picker.js +50 -55
- package/dist/ui/widgets/confirm-card.js +193 -0
- package/dist/ui/widgets/dep-graph.js +3 -2
- package/dist/ui/widgets/diff-viewer.js +6 -5
- package/dist/ui/widgets/files-card.js +97 -0
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
- package/dist/ui/widgets/shortcuts-sheet.js +25 -0
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/upgrade-check.js +39 -8
- package/package.json +11 -2
- package/test-harness/fe-draft/app.ts +188 -0
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
- package/test-harness/fe-draft/routes.ts +224 -0
- package/test-harness/fe-draft/routes.unit.test.ts +45 -0
- package/test-harness/fe-draft/server.ts +225 -0
- package/test-harness/fe-draft/variants.ts +196 -0
- package/vitest.smoke-e2e.config.ts +20 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// GENERATED FILE — do not edit by hand.
|
|
2
2
|
// Build artifact: ed25519 public key for SKILL.md body verification.
|
|
3
3
|
// Regenerated by `pnpm prebuild` on each release.
|
|
4
|
-
export const MANIFEST_PUBLIC_KEYS = ['a7Qj8ibfLTj49kQRTn1DOE/UAExuoBgjhaXxf33+JPY='];
|
|
4
|
+
export const MANIFEST_PUBLIC_KEYS = ['Nexqg3TSUYzVPirLv6h1tbCmpQF7MbAtVDmqoLAie+8=', 'a7Qj8ibfLTj49kQRTn1DOE/UAExuoBgjhaXxf33+JPY='];
|
|
@@ -156,7 +156,7 @@ export async function readStandardsSource(src) {
|
|
|
156
156
|
return fs.readFile(src, 'utf8');
|
|
157
157
|
}
|
|
158
158
|
async function defaultPrompts() {
|
|
159
|
-
const { input, select } = await import('
|
|
159
|
+
const { input, select } = await import('../repl/themed-prompts.js');
|
|
160
160
|
return {
|
|
161
161
|
select: (opts) => select({ message: opts.message, choices: opts.choices, default: opts.default }),
|
|
162
162
|
input: (opts) => input({ message: opts.message, default: opts.default }),
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Channel-agnostic answerer for behaviour pins (piece 2, Part 4).
|
|
2
|
+
//
|
|
3
|
+
// Lives under src/ (not test/helpers/ as the plan named it): the pins that
|
|
4
|
+
// use it sit in src/**/__tests__, which tsc type-checks with rootDir 'src',
|
|
5
|
+
// so a helper outside src/ fails `pnpm typecheck` (TS6059).
|
|
6
|
+
//
|
|
7
|
+
// A confirm today goes through askQuestionEmitter (the v1 modal). After its
|
|
8
|
+
// migration it goes through cardSlotEmitter ('confirm'). This helper listens
|
|
9
|
+
// on BOTH and feeds the same logical answers in order, so a pin written before
|
|
10
|
+
// the migration stays valid, unchanged, after it. `null` = Esc / cancelled.
|
|
11
|
+
import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
|
|
12
|
+
import { cardSlotEmitter } from '../ui/card-slot.js';
|
|
13
|
+
export function answerAny(...answers) {
|
|
14
|
+
const seen = [];
|
|
15
|
+
let i = 0;
|
|
16
|
+
const next = () => (i < answers.length ? (answers[i++] ?? null) : null);
|
|
17
|
+
const onAsk = (req) => {
|
|
18
|
+
seen.push({
|
|
19
|
+
id: req.id,
|
|
20
|
+
values: req.choices.map((c) => c.value),
|
|
21
|
+
initial: 0,
|
|
22
|
+
text: `${req.question}\n${req.context ?? ''}`,
|
|
23
|
+
});
|
|
24
|
+
const a = next();
|
|
25
|
+
req.resolve(a === null ? { answer: null, cancelled: true } : { answer: a });
|
|
26
|
+
};
|
|
27
|
+
const onCard = (req) => {
|
|
28
|
+
seen.push({
|
|
29
|
+
id: req.id,
|
|
30
|
+
values: req.choices.map((c) => c.value),
|
|
31
|
+
initial: req.initial ?? 0,
|
|
32
|
+
text: `${req.title}\n${(req.facts ?? []).join('\n')}`,
|
|
33
|
+
});
|
|
34
|
+
const a = next();
|
|
35
|
+
req.resolve(a === null ? { value: null, cancelled: true } : { value: a });
|
|
36
|
+
};
|
|
37
|
+
askQuestionEmitter.on('request', onAsk);
|
|
38
|
+
cardSlotEmitter.on('confirm', onCard);
|
|
39
|
+
return {
|
|
40
|
+
seen,
|
|
41
|
+
dispose: () => {
|
|
42
|
+
askQuestionEmitter.off('request', onAsk);
|
|
43
|
+
cardSlotEmitter.off('confirm', onCard);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
package/dist/tools/approval.js
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { registerTool } from './index.js';
|
|
3
|
-
import { isHeadless } from '../renderer/tty.js';
|
|
4
|
-
import { effectiveRisk } from '../approvals/risk-floor.js';
|
|
3
|
+
import { isHeadless, shouldUseInk } from '../renderer/tty.js';
|
|
4
|
+
import { effectiveRisk, riskReasonText } from '../approvals/risk-floor.js';
|
|
5
|
+
import { objectsWithSyntaxErrors } from './syntax-state.js';
|
|
6
|
+
import { noteWriteTransport } from '../repl/current-transport.js';
|
|
7
|
+
/**
|
|
8
|
+
* Round 8 item 6 — the footer showed "no transport" while the approved card
|
|
9
|
+
* said "transport S4HK903731". An approval granted for an explicit transport
|
|
10
|
+
* is the transport chosen for this work: the footer shows it (display-only
|
|
11
|
+
* wire — the session transport pointer and write fallback are untouched).
|
|
12
|
+
*/
|
|
13
|
+
function noteApprovedTransport(t) {
|
|
14
|
+
if (typeof t === 'string' && /^[A-Z0-9]{3}K\d{6}$/i.test(t.trim()))
|
|
15
|
+
noteWriteTransport(t.trim().toUpperCase());
|
|
16
|
+
}
|
|
5
17
|
import { mintChangeApproval } from '../approvals/jwt.js';
|
|
6
18
|
import { canonicalApprovalObject } from '../approvals/canonical.js';
|
|
7
19
|
import { renderPlanGate, renderPerChangeApprovalV3 } from '../approvals/render.js';
|
|
@@ -26,7 +38,7 @@ import { isGuardedRunActive, getCurrentPhaseWrites, notePlanDeviation, PLAN_DEVI
|
|
|
26
38
|
* unused — the prompt and render are sapAlias-agnostic.
|
|
27
39
|
*/
|
|
28
40
|
export async function handleAdvisoryApproval(args, _ctx) {
|
|
29
|
-
const render = renderAdvisoryProposal(args);
|
|
41
|
+
const render = renderAdvisoryProposal(args, { interactiveHint: !shouldUseInk() });
|
|
30
42
|
// B5 (2026-06-11) — headless: the advisory prompt blocks on stdin that
|
|
31
43
|
// will never answer. Render the proposal (it IS the run's deliverable —
|
|
32
44
|
// the developer reads the captured output and applies manually), skip
|
|
@@ -185,6 +197,7 @@ registerTool({
|
|
|
185
197
|
const riskCtx = {
|
|
186
198
|
sapAlias: ctx.sapAlias,
|
|
187
199
|
autoApprovePolicy: sapCfg?.auto_approve ?? 'never',
|
|
200
|
+
syntaxErrorObjects: objectsWithSyntaxErrors(),
|
|
188
201
|
};
|
|
189
202
|
const changes = args.changes;
|
|
190
203
|
const declared = args.risk;
|
|
@@ -233,18 +246,19 @@ registerTool({
|
|
|
233
246
|
for (const c of changes) {
|
|
234
247
|
// Task A3: mint via the canonical path — the JWT stores the same
|
|
235
248
|
// object string the write tools' validators will compare against.
|
|
236
|
-
ids.push(await mintChangeApproval(c, ctx.session.id));
|
|
249
|
+
ids.push(await mintChangeApproval(c, ctx.session.id, { auto: true }));
|
|
237
250
|
}
|
|
238
251
|
// Tell Rule 8 the user has already covered this batch — they
|
|
239
252
|
// configured auto-approve, so no per-dispatch re-prompts.
|
|
240
253
|
markPlanGateApproved();
|
|
254
|
+
noteApprovedTransport(args.transport); // round 8 item 6
|
|
241
255
|
return {
|
|
242
256
|
content: JSON.stringify({
|
|
243
257
|
approved: true,
|
|
244
258
|
auto_approved: true,
|
|
245
259
|
approval_ids: ids,
|
|
246
260
|
effective_risk: eff.level,
|
|
247
|
-
raised_by: eff.raisedBy,
|
|
261
|
+
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
248
262
|
}),
|
|
249
263
|
};
|
|
250
264
|
}
|
|
@@ -266,7 +280,7 @@ registerTool({
|
|
|
266
280
|
'No approval IDs were minted — do not attempt any write operations. ' +
|
|
267
281
|
'Summarise the proposed changes so the user can re-run interactively or switch write_mode to "advisory-only".',
|
|
268
282
|
effective_risk: eff.level,
|
|
269
|
-
raised_by: eff.raisedBy,
|
|
283
|
+
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
270
284
|
}),
|
|
271
285
|
is_error: true,
|
|
272
286
|
};
|
|
@@ -278,14 +292,14 @@ registerTool({
|
|
|
278
292
|
// gate. Cancel rejects the whole plan.
|
|
279
293
|
let skipPerChangeGates = false;
|
|
280
294
|
if (changes.length > 1) {
|
|
281
|
-
const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff);
|
|
295
|
+
const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff, ctx.sapAlias);
|
|
282
296
|
if (mode === 'cancel') {
|
|
283
297
|
return {
|
|
284
298
|
content: JSON.stringify({
|
|
285
299
|
approved: false,
|
|
286
300
|
reason: 'plan_rejected',
|
|
287
301
|
effective_risk: eff.level,
|
|
288
|
-
raised_by: eff.raisedBy,
|
|
302
|
+
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
289
303
|
}),
|
|
290
304
|
};
|
|
291
305
|
}
|
|
@@ -347,7 +361,7 @@ registerTool({
|
|
|
347
361
|
approved: false,
|
|
348
362
|
reason: 'user_cancelled_turn',
|
|
349
363
|
effective_risk: eff.level,
|
|
350
|
-
raised_by: eff.raisedBy,
|
|
364
|
+
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
351
365
|
}),
|
|
352
366
|
};
|
|
353
367
|
}
|
|
@@ -364,15 +378,17 @@ registerTool({
|
|
|
364
378
|
// Tell Rule 8 the user has explicitly approved (some/all of) this
|
|
365
379
|
// batch via the plan-gate / per-change flow. Suppresses the redundant
|
|
366
380
|
// Rule 8 prompt at every subsequent dispatch this turn.
|
|
367
|
-
if (approvalIds.length > 0)
|
|
381
|
+
if (approvalIds.length > 0) {
|
|
368
382
|
markPlanGateApproved();
|
|
383
|
+
noteApprovedTransport(args.transport); // round 8 item 6
|
|
384
|
+
}
|
|
369
385
|
return {
|
|
370
386
|
content: JSON.stringify({
|
|
371
387
|
approved: approvalIds.length > 0,
|
|
372
388
|
approval_ids: approvalIds,
|
|
373
389
|
skipped: rejections,
|
|
374
390
|
effective_risk: eff.level,
|
|
375
|
-
raised_by: eff.raisedBy,
|
|
391
|
+
raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
|
|
376
392
|
}),
|
|
377
393
|
};
|
|
378
394
|
},
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* it just got back and can cross-reference the question id.
|
|
23
23
|
*
|
|
24
24
|
* UX Wave 2 — v2 batched `questions` shape (up to 4 related questions in
|
|
25
|
-
* ONE call): Ink renders a single <
|
|
25
|
+
* ONE call): Ink renders a single <AskCard> (Task 2); classic runs the
|
|
26
26
|
* questions as a sequential inquirer flow (Task 3). Both return
|
|
27
27
|
* `{ formId, answers: [{id, answer, custom?}], cancelled? }`.
|
|
28
28
|
*/
|
|
@@ -32,8 +32,24 @@ import { input, select, checkbox } from '@inquirer/prompts';
|
|
|
32
32
|
import { withInquirer } from '../repl/inquirer-guard.js';
|
|
33
33
|
import { shouldUseInk, isHeadless } from '../renderer/tty.js';
|
|
34
34
|
import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
|
|
35
|
+
import { emitTrace, formatAnswerTrace } from '../renderer/trace.js';
|
|
36
|
+
import { truncateToWidth, uiWidth } from '../renderer/ui-width.js';
|
|
37
|
+
import { answerDisplayText } from '../ui/ask-answer-text.js';
|
|
35
38
|
/** Chip labels in the v2 form are capped at 12 chars (brief-binding). */
|
|
36
39
|
const HEADER_MAX_CHARS = 12;
|
|
40
|
+
/**
|
|
41
|
+
* Piece 2 / Q1c — scrollback trace labels (spec §3 "Scrollback trace").
|
|
42
|
+
* Ink only: classic inquirer already leaves its own answered line.
|
|
43
|
+
* v1 has no chip header, so the question is cut to a short label; v2 uses
|
|
44
|
+
* each question's header (the chip text the user saw).
|
|
45
|
+
*/
|
|
46
|
+
const TRACE_QUESTION_CELLS = 48;
|
|
47
|
+
function v1TraceLabel(question) {
|
|
48
|
+
return truncateToWidth(question, TRACE_QUESTION_CELLS);
|
|
49
|
+
}
|
|
50
|
+
function formTraceLabel(form) {
|
|
51
|
+
return form.questions.map((q) => q.header ?? q.id).join(' · ');
|
|
52
|
+
}
|
|
37
53
|
/**
|
|
38
54
|
* UX Wave 2 / Task 1 — validation failure for a v2 `questions` payload.
|
|
39
55
|
* The handler converts this into an error RESULT (`ask_form_invalid`),
|
|
@@ -45,6 +61,17 @@ export class AskFormValidationError extends Error {
|
|
|
45
61
|
this.name = 'AskFormValidationError';
|
|
46
62
|
}
|
|
47
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Fix round 3 (item 9c) — an option that plainly stops the flow ends a form
|
|
66
|
+
* even when the model forgot `ends_form`: asking the next question after
|
|
67
|
+
* "Stop here" was the live defect. Review I1: inferred ONLY from a "Stop" /
|
|
68
|
+
* "Stop here" label. "Cancel" / "Abort" can be real answers (a RAP action
|
|
69
|
+
* named Cancel), so they end the form only with an explicit `ends_form: true`.
|
|
70
|
+
*/
|
|
71
|
+
const STOP_LABEL = /^stop( here)?$/i;
|
|
72
|
+
function isStopOption(o) {
|
|
73
|
+
return STOP_LABEL.test(o.label.trim());
|
|
74
|
+
}
|
|
48
75
|
function normalizeOptions(raw) {
|
|
49
76
|
if (!Array.isArray(raw))
|
|
50
77
|
return [];
|
|
@@ -54,6 +81,8 @@ function normalizeOptions(raw) {
|
|
|
54
81
|
opt.description = String(o.description);
|
|
55
82
|
if (o.recommended === true)
|
|
56
83
|
opt.recommended = true;
|
|
84
|
+
if (o.ends_form === true || isStopOption(opt))
|
|
85
|
+
opt.endsForm = true;
|
|
57
86
|
return opt;
|
|
58
87
|
});
|
|
59
88
|
}
|
|
@@ -167,7 +196,7 @@ export function pickHeadlessChoices(kind, choices) {
|
|
|
167
196
|
}
|
|
168
197
|
registerTool({
|
|
169
198
|
name: 'ask_question',
|
|
170
|
-
description: "Ask the user a clarifying question and get their answer. Use this for ANY user-decision point in a skill conversation — object names, design choices, approval checkpoints, etc. The CLI renders a formatted prompt, waits for the user's answer, and returns it as the tool result. Prefer this over emitting a text question — it is the only reliable mechanism for mid-turn user interaction. When you have MULTIPLE related questions, batch up to 4 into ONE call via `questions` — the user answers them in a single form. Never ask more than 4 at once; never split related questions across calls.",
|
|
199
|
+
description: "Ask the user a clarifying question and get their answer. Use this for ANY user-decision point in a skill conversation — object names, design choices, approval checkpoints, etc. The CLI renders a formatted prompt, waits for the user's answer, and returns it as the tool result. Prefer this over emitting a text question — it is the only reliable mechanism for mid-turn user interaction. When you have MULTIPLE related questions, batch up to 4 into ONE call via `questions` — the user answers them in a single form. Never ask more than 4 at once; never split related questions across calls. Before asking the user to confirm a plan, write the plan out in your message first (objects, order, transport) — the form shows only the question, so a plan you have not written is a plan the user cannot see.",
|
|
171
200
|
isMutating: false,
|
|
172
201
|
input_schema: {
|
|
173
202
|
type: 'object',
|
|
@@ -237,6 +266,10 @@ registerTool({
|
|
|
237
266
|
type: 'boolean',
|
|
238
267
|
description: 'Mark the option you would recommend. Auto-picked in headless runs.',
|
|
239
268
|
},
|
|
269
|
+
ends_form: {
|
|
270
|
+
type: 'boolean',
|
|
271
|
+
description: 'Picking this option ends the form at once (e.g. "Stop here", or a Cancel that means stop asking); the remaining questions come back with answer null. Only a "Stop" / "Stop here" label is treated this way automatically — set this for any other stop option.',
|
|
272
|
+
},
|
|
240
273
|
},
|
|
241
274
|
required: ['value', 'label'],
|
|
242
275
|
},
|
|
@@ -300,17 +333,24 @@ registerTool({
|
|
|
300
333
|
};
|
|
301
334
|
}
|
|
302
335
|
// Interactive v2, INK path — wired by Task 2: requestForm emits the
|
|
303
|
-
// discriminated form request, App routes it to <
|
|
304
|
-
//
|
|
336
|
+
// discriminated form request, App routes it to <AskCard> (v1 requests
|
|
337
|
+
// render the same AskCard), and the resolved
|
|
305
338
|
// AskFormResult comes back here. Cancel (Esc / no listener) mirrors
|
|
306
339
|
// the v1 cancel contract: empty answers + cancelled: true.
|
|
307
340
|
if (shouldUseInk()) {
|
|
308
341
|
const result = await askQuestionEmitter.requestForm(form);
|
|
309
342
|
if (result.cancelled) {
|
|
343
|
+
emitTrace(formatAnswerTrace(formTraceLabel(form), 'cancelled', uiWidth()));
|
|
310
344
|
return {
|
|
311
345
|
content: JSON.stringify({ formId: form.formId, answers: [], cancelled: true }),
|
|
312
346
|
};
|
|
313
347
|
}
|
|
348
|
+
for (const a of result.answers) {
|
|
349
|
+
const q = form.questions.find((qq) => qq.id === a.id);
|
|
350
|
+
if (!q || a.answer === null)
|
|
351
|
+
continue;
|
|
352
|
+
emitTrace(formatAnswerTrace(q.header ?? q.id, answerDisplayText(a.answer, q.options, a.custom === true), uiWidth()));
|
|
353
|
+
}
|
|
314
354
|
return {
|
|
315
355
|
content: JSON.stringify({ formId: result.formId, answers: result.answers }),
|
|
316
356
|
};
|
|
@@ -322,7 +362,7 @@ registerTool({
|
|
|
322
362
|
// the v1 building blocks: select + "(type a custom answer)" escape
|
|
323
363
|
// for pick-one, checkbox for multiSelect, input for freeText.
|
|
324
364
|
//
|
|
325
|
-
// Result shapes mirror the Ink
|
|
365
|
+
// Result shapes mirror the Ink AskCard EXACTLY (Task-2 contract):
|
|
326
366
|
// success → { formId, answers: [{ id, answer, custom? }] } — in
|
|
327
367
|
// question order; custom:true ONLY for the escape.
|
|
328
368
|
// Esc anywhere (ExitPromptError) → { formId, answers: [],
|
|
@@ -487,8 +527,10 @@ registerTool({
|
|
|
487
527
|
choices: choices ?? [],
|
|
488
528
|
});
|
|
489
529
|
if (result.cancelled || result.answer === null || result.answer.length === 0) {
|
|
530
|
+
emitTrace(formatAnswerTrace(v1TraceLabel(question), 'cancelled', uiWidth()));
|
|
490
531
|
return { content: JSON.stringify({ id, kind, answer: null, cancelled: true }) };
|
|
491
532
|
}
|
|
533
|
+
emitTrace(formatAnswerTrace(v1TraceLabel(question), answerDisplayText(result.answer, kind === 'text' ? [] : (choices ?? [])), uiWidth()));
|
|
492
534
|
return { content: JSON.stringify({ id, kind, answer: result.answer }) };
|
|
493
535
|
}
|
|
494
536
|
// Classic mode — inquirer path (unchanged from v0.6).
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Flag-gated: invisible to listTools() unless CSPEACH_TOOL_FILE_WRITE=on.
|
|
19
19
|
*/
|
|
20
|
-
import { promises as fs } from 'node:fs';
|
|
20
|
+
import { promises as fs, readFileSync } from 'node:fs';
|
|
21
21
|
import * as path from 'node:path';
|
|
22
22
|
import { registerTool } from '../index.js';
|
|
23
23
|
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
|
|
@@ -30,13 +30,30 @@ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIS
|
|
|
30
30
|
*/
|
|
31
31
|
let guideNoteShown = false;
|
|
32
32
|
export function __resetGuideNoteForTests() { guideNoteShown = false; }
|
|
33
|
+
/**
|
|
34
|
+
* Dry run 2026-09-25: a CAP app's GUIDE.md was announced as "applying these
|
|
35
|
+
* objects in SAP". A CAP project (package.json depending on @sap/cds) has no
|
|
36
|
+
* SAP objects — its guide is about running and checking the app.
|
|
37
|
+
*/
|
|
38
|
+
function isCapProject(cwd) {
|
|
39
|
+
try {
|
|
40
|
+
const pkg = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
41
|
+
return Boolean(pkg?.dependencies?.['@sap/cds'] ?? pkg?.devDependencies?.['@sap/cds']);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
33
47
|
function noteGuideWritten(relPath, ctx) {
|
|
34
48
|
if (guideNoteShown)
|
|
35
49
|
return;
|
|
36
50
|
if (path.basename(relPath).toLowerCase() !== 'guide.md')
|
|
37
51
|
return;
|
|
38
52
|
guideNoteShown = true;
|
|
39
|
-
|
|
53
|
+
const what = isCapProject(ctx.cwd)
|
|
54
|
+
? 'step-by-step list for running and checking this app'
|
|
55
|
+
: 'step-by-step list for applying these objects in SAP';
|
|
56
|
+
ctx.chunkEmitter?.emit('info', `${relPath} is your ${what} — `
|
|
40
57
|
+ 'open it at https://cspeach.dev/guide, or in any text editor. '
|
|
41
58
|
+ 'If a step fails, come back here and say "step N failed" with the error.');
|
|
42
59
|
}
|
|
@@ -36,6 +36,21 @@ function isMissingFeLibError(msg) {
|
|
|
36
36
|
* flush the result to disk. Throws NotV4FeAppError when the target is not a V4
|
|
37
37
|
* FE app; rethrows any other writer error unchanged.
|
|
38
38
|
*/
|
|
39
|
+
/**
|
|
40
|
+
* A custom action must always get a working press handler. fe-fpm-writer reads
|
|
41
|
+
* `eventHandler` at the TOP level of the action config; a flag passed inside
|
|
42
|
+
* `settings` was silently ignored and the action was written with press "" — a
|
|
43
|
+
* button that does nothing (on UI5 1.136 without even an error). So: hoist a
|
|
44
|
+
* `settings.eventHandler` to the top level, and when no handler is given at all,
|
|
45
|
+
* generate one (true = a new ext/<Name> handler module).
|
|
46
|
+
*/
|
|
47
|
+
export function withActionHandler(params) {
|
|
48
|
+
const settings = { ...(params.settings ?? {}) };
|
|
49
|
+
const fromSettings = settings.eventHandler;
|
|
50
|
+
delete settings.eventHandler;
|
|
51
|
+
const eventHandler = params.eventHandler ?? fromSettings ?? true;
|
|
52
|
+
return { ...params, settings, eventHandler };
|
|
53
|
+
}
|
|
39
54
|
export async function feExtend(opts) {
|
|
40
55
|
// Dynamic import: keep the heavy writer out of the cold-start path (mirrors
|
|
41
56
|
// the engine's lazy-load convention; only paid when the tool actually runs).
|
|
@@ -50,7 +65,7 @@ export async function feExtend(opts) {
|
|
|
50
65
|
result = await fpm.generateCustomColumn(opts.basePath, opts.params, editor);
|
|
51
66
|
break;
|
|
52
67
|
case 'custom-action':
|
|
53
|
-
result = await fpm.generateCustomAction(opts.basePath, opts.params, editor);
|
|
68
|
+
result = await fpm.generateCustomAction(opts.basePath, withActionHandler(opts.params), editor);
|
|
54
69
|
break;
|
|
55
70
|
case 'custom-section':
|
|
56
71
|
result = await fpm.generateCustomSection(opts.basePath, opts.params, editor);
|
|
@@ -1,12 +1,108 @@
|
|
|
1
|
-
// cspeach-cli/src/tools/fiori/fe-scaffold.ts
|
|
2
|
-
import { generate, TemplateType, OdataVersion } from '@sap-ux/fiori-elements-writer';
|
|
3
1
|
import { create } from 'mem-fs';
|
|
4
2
|
import { create as createEditor } from 'mem-fs-editor';
|
|
5
3
|
import { join } from 'node:path';
|
|
6
|
-
|
|
4
|
+
import { PINNED_UI5_VERSION } from './ui5-version.js';
|
|
5
|
+
// The ONE pinned, CDN-hosted patch (ui5-version.ts) — never an x.y.0 guess.
|
|
6
|
+
const DEFAULT_UI5_VERSION = PINNED_UI5_VERSION;
|
|
7
|
+
/**
|
|
8
|
+
* F-4: a generated list report loads its rows when it opens. The writers leave
|
|
9
|
+
* the FE default, which on a draft service shows "To start, set the relevant
|
|
10
|
+
* filters and choose Go" and no data — a user sees an empty app and the render
|
|
11
|
+
* smoke's `rows` check has nothing to count.
|
|
12
|
+
* V4 (sap.fe.templates.ListReport targets — LROP, Worklist, ALP): initialLoad "Enabled".
|
|
13
|
+
* V2 (sap.ui.generic.app list pages): dataLoadSettings.loadDataOnAppLaunch "always".
|
|
14
|
+
* Object pages and OVP are untouched.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The V4 `initialLoad` value for the target UI5 version. sap.fe's ListComponent
|
|
18
|
+
* typed it as a BOOLEAN up to 1.84 (S/4HANA 2020) and as
|
|
19
|
+
* sap.fe.core.InitialLoadMode ("Auto" | "Enabled" | "Disabled") from 1.96 —
|
|
20
|
+
* checked against the CDN sources 2026-09-24 (1.85–1.95 are not hosted and not
|
|
21
|
+
* shipped with an S/4 release). The wrong type throws during component creation
|
|
22
|
+
* and the list never renders.
|
|
23
|
+
*/
|
|
24
|
+
export function initialLoadSetting(ui5Version) {
|
|
25
|
+
const minor = Number(/^1\.(\d+)/.exec(ui5Version)?.[1] ?? NaN);
|
|
26
|
+
return Number.isFinite(minor) && minor < 96 ? true : 'Enabled';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 2026-09-25: UI5 tooling 4's dev server (`fiori run` for ABAP apps,
|
|
30
|
+
* cds-plugin-ui5 for CAP apps) runs the manifest enhancer, which derives
|
|
31
|
+
* supportedLocales from the i18n files an app ships — only i18n.properties, so
|
|
32
|
+
* [""] — but adds no fallbackLocale, and UI5's default fallback 'en' is then
|
|
33
|
+
* "not contained in the list of supported locales", a console error at every
|
|
34
|
+
* boot. Declaring both on every bundle is the fix the tooling's own warning
|
|
35
|
+
* names ("configure another available fallbackLocale").
|
|
36
|
+
*/
|
|
37
|
+
function declareBaseLocale(fs, manifestPath) {
|
|
38
|
+
if (!fs.exists(manifestPath))
|
|
39
|
+
return;
|
|
40
|
+
const manifest = fs.readJSON(manifestPath);
|
|
41
|
+
const locales = { supportedLocales: [''], fallbackLocale: '' };
|
|
42
|
+
const app = manifest?.['sap.app'];
|
|
43
|
+
if (app && typeof app.i18n === 'string')
|
|
44
|
+
app.i18n = { bundleUrl: app.i18n, ...locales };
|
|
45
|
+
else if (app?.i18n && typeof app.i18n === 'object')
|
|
46
|
+
Object.assign(app.i18n, locales);
|
|
47
|
+
const models = manifest?.['sap.ui5']?.models ?? {};
|
|
48
|
+
for (const m of Object.values(models)) {
|
|
49
|
+
if (m?.type === 'sap.ui.model.resource.ResourceModel')
|
|
50
|
+
m.settings = { ...(m.settings ?? {}), ...locales };
|
|
51
|
+
}
|
|
52
|
+
fs.writeJSON(manifestPath, manifest);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Dry run 2026-09-25: under cds-plugin-ui5 the app's ui5 middlewares run inside
|
|
56
|
+
* the CAP server process. fiori-tools-preview loads @sap/cds-dk's own nested
|
|
57
|
+
* @sap/cds — a second CAP copy — and every draft create then crashes the server
|
|
58
|
+
* ("no primary database" → lean-draft "Maximum call stack size exceeded"). The
|
|
59
|
+
* launchpad page is a real file (test/flpSandbox.html), so the preview
|
|
60
|
+
* middleware is not needed there. Exactly that entry goes; anything else
|
|
61
|
+
* unexpected in the writer's ui5.yaml fails loudly instead of shipping a crash.
|
|
62
|
+
*/
|
|
63
|
+
export function withoutFioriToolsPreview(yaml) {
|
|
64
|
+
const lines = yaml.split(/\r?\n/);
|
|
65
|
+
const start = lines.findIndex((l) => /^ {4}- name: fiori-tools-preview\s*$/.test(l));
|
|
66
|
+
if (start < 0)
|
|
67
|
+
throw new Error('ui5.yaml has no fiori-tools-preview middleware entry to remove');
|
|
68
|
+
// The entry is every following line indented deeper than its "- name:" line.
|
|
69
|
+
let end = start + 1;
|
|
70
|
+
while (end < lines.length && /^ {5,}\S/.test(lines[end]))
|
|
71
|
+
end++;
|
|
72
|
+
return [...lines.slice(0, start), ...lines.slice(end)].join('\n');
|
|
73
|
+
}
|
|
74
|
+
function loadListsOnOpen(fs, manifestPath, ui5Version) {
|
|
75
|
+
if (!fs.exists(manifestPath))
|
|
76
|
+
return;
|
|
77
|
+
const manifest = fs.readJSON(manifestPath);
|
|
78
|
+
let changed = false;
|
|
79
|
+
const targets = manifest?.['sap.ui5']?.routing?.targets ?? {};
|
|
80
|
+
for (const t of Object.values(targets)) {
|
|
81
|
+
if (t?.name === 'sap.fe.templates.ListReport') {
|
|
82
|
+
t.options ??= {};
|
|
83
|
+
t.options.settings ??= {};
|
|
84
|
+
t.options.settings.initialLoad = initialLoadSetting(ui5Version);
|
|
85
|
+
changed = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const pages = manifest?.['sap.ui.generic.app']?.pages ?? {};
|
|
89
|
+
for (const p of Object.values(pages)) {
|
|
90
|
+
const name = p?.component?.name;
|
|
91
|
+
if (name === 'sap.suite.ui.generic.template.ListReport' || name === 'sap.suite.ui.generic.template.AnalyticalListPage') {
|
|
92
|
+
p.component.settings ??= {};
|
|
93
|
+
p.component.settings.dataLoadSettings = { loadDataOnAppLaunch: 'always' };
|
|
94
|
+
changed = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (changed)
|
|
98
|
+
fs.writeJSON(manifestPath, manifest);
|
|
99
|
+
}
|
|
7
100
|
/** Scaffold a Fiori Elements shell and FULLY flush it to disk. Relies on the
|
|
8
101
|
* service's backend @UI annotations — authors no local annotation.xml. */
|
|
9
102
|
export async function scaffoldFioriElements(opts) {
|
|
103
|
+
// Loaded on use (fix round 3, item 11e): this writer's module graph alone
|
|
104
|
+
// cost ~2.8 s of every CLI start, Fiori work or not.
|
|
105
|
+
const { generate, TemplateType, OdataVersion } = await import('@sap-ux/fiori-elements-writer');
|
|
10
106
|
const ui5Version = opts.ui5Version ?? DEFAULT_UI5_VERSION;
|
|
11
107
|
const version = opts.service.version === '2.0' ? OdataVersion.v2 : OdataVersion.v4;
|
|
12
108
|
// Per-template settings (spike §3): LROP/Worklist/ALP use
|
|
@@ -62,6 +158,12 @@ export async function scaffoldFioriElements(opts) {
|
|
|
62
158
|
ui5: { minUI5Version: ui5Version, version: ui5Version },
|
|
63
159
|
};
|
|
64
160
|
await generate(opts.basePath, config, fs);
|
|
161
|
+
loadListsOnOpen(fs, join(opts.basePath, 'webapp', 'manifest.json'), ui5Version);
|
|
162
|
+
declareBaseLocale(fs, join(opts.basePath, 'webapp', 'manifest.json'));
|
|
163
|
+
if (opts.backend === 'cap') {
|
|
164
|
+
const ui5YamlPath = join(opts.basePath, 'ui5.yaml');
|
|
165
|
+
fs.write(ui5YamlPath, withoutFioriToolsPreview(fs.read(ui5YamlPath)));
|
|
166
|
+
}
|
|
65
167
|
if (!opts.localAnnotations) {
|
|
66
168
|
// @sap-ux writers force-inject a 'localAnnotationsName: annotation' dataSource for
|
|
67
169
|
// EDMX services even when none was requested, but only write the XML file when
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/fiori/html-escapes.ts
|
|
2
|
+
//
|
|
3
|
+
// Dry run 2026-09-25: an app header showed "Suppliers & Quality
|
|
4
|
+
// Certificates". Two sources: the model sent the title already HTML-escaped,
|
|
5
|
+
// and the freestyle writer HTML-escapes the main view title into
|
|
6
|
+
// i18n.properties. i18n texts and titles are plain text — UI5 shows "&"
|
|
7
|
+
// literally — so escapes are decoded, in ONE pass ("&lt;" becomes "<").
|
|
8
|
+
const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
9
|
+
export function decodeHtmlEscapes(text) {
|
|
10
|
+
return text.replace(/&(amp|lt|gt|quot|apos|#\d{1,7}|#x[0-9a-fA-F]{1,6});/g, (m, name) => {
|
|
11
|
+
if (name[0] !== '#')
|
|
12
|
+
return NAMED[name] ?? m;
|
|
13
|
+
const code = name[1] === 'x' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
|
|
14
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : m;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** Decode the VALUE of every `key=value` line of a .properties file; comments and keys untouched. */
|
|
18
|
+
export function decodePropertiesValues(properties) {
|
|
19
|
+
return properties
|
|
20
|
+
.split(/(\r?\n)/)
|
|
21
|
+
.map((part) => {
|
|
22
|
+
const eq = part.indexOf('=');
|
|
23
|
+
if (eq < 0 || /^\s*[#!]/.test(part))
|
|
24
|
+
return part;
|
|
25
|
+
return part.slice(0, eq + 1) + decodeHtmlEscapes(part.slice(eq + 1));
|
|
26
|
+
})
|
|
27
|
+
.join('');
|
|
28
|
+
}
|