@cspeach/cli 1.1.53 → 1.1.55
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/agent/loop.js +90 -7
- package/dist/agent/tool-dispatch.js +11 -0
- package/dist/auth/auth-file.js +3 -0
- package/dist/auth/key-resolver.js +8 -3
- package/dist/auth/me.js +6 -2
- package/dist/cli.js +25 -0
- package/dist/commands/login.js +22 -4
- package/dist/commands/plan-reground.js +5 -0
- package/dist/commands/sap-link.js +14 -1
- package/dist/commands/team-build-help.js +19 -0
- package/dist/commands/team-build.js +97 -0
- package/dist/commands/team-plan.js +2 -2
- package/dist/commands/team-spec.js +107 -0
- package/dist/commands/team.js +111 -4
- package/dist/journey/chain.js +8 -0
- package/dist/journey/clean-core.js +2 -2
- package/dist/journey/rebuild.js +8 -0
- package/dist/journey/run.js +9 -0
- package/dist/lane/ready-spec.js +32 -0
- package/dist/licence/account-name.js +31 -0
- package/dist/licence/paths.js +5 -0
- package/dist/licence/seat-kind.js +110 -0
- package/dist/licence/seat-state.js +4 -0
- package/dist/licence/terminal-seat.js +43 -0
- package/dist/licence/turn-gate.js +4 -0
- package/dist/one-shot.js +32 -0
- package/dist/projects/answer-blockers.js +31 -7
- package/dist/projects/blocker-answers-note.js +1 -1
- package/dist/projects/save-command.js +37 -2
- package/dist/projects/saved-lines.js +19 -17
- package/dist/projects/spec-share.js +270 -0
- package/dist/projects/team-build-context.js +49 -0
- package/dist/register/picture.js +2 -2
- package/dist/register/store.js +19 -0
- package/dist/renderer/routing-line.js +11 -0
- package/dist/repl/builtin-commands.js +4 -3
- package/dist/repl/file-picker.js +52 -5
- package/dist/repl/mode-cycle.js +12 -0
- package/dist/repl/open-questions-gate.js +193 -0
- package/dist/repl/ready-phrase.js +108 -0
- package/dist/repl/startup-account.js +64 -1
- package/dist/repl/team-build-screen.js +124 -0
- package/dist/repl/team-build-start.js +179 -0
- package/dist/repl.js +227 -10
- package/dist/sap-link/policy.js +4 -0
- package/dist/sap-link/registry.js +9 -3
- package/dist/sap-link/server.js +4 -1
- package/dist/tools/_team-work-path.js +25 -1
- package/dist/tools/cap/sap-link-tool.js +2 -0
- package/dist/tools/filesystem/file-edit.js +2 -2
- package/dist/tools/filesystem/file-write.js +2 -2
- package/dist/tools/hand-off.js +16 -1
- package/dist/tools/project/spec_answers_record.js +62 -9
- package/dist/tools/project/team_findings.js +2 -2
- package/dist/tools/seat-gate.js +41 -0
- package/dist/ui/when-listening.js +34 -0
- package/dist/ui/widgets/files-card.js +19 -1
- package/package.json +1 -1
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team build (spec 2026-10-25 §7.2) — the folder sets itself up when someone
|
|
3
|
+
* types `cspeach`. Nobody types a team command:
|
|
4
|
+
*
|
|
5
|
+
* - a functional seat in a folder that is not a team project gets a team
|
|
6
|
+
* build, with one line. A folder that already holds files asks first
|
|
7
|
+
* (default No), so opening CSPeach in Documents by mistake changes nothing.
|
|
8
|
+
* - a developer is offered a team build once per folder (default No), never
|
|
9
|
+
* in the home folder. The answer is remembered, so a scratch folder sees
|
|
10
|
+
* the question once and nothing changes.
|
|
11
|
+
* - anyone in a team build folder they have not joined is joined, one line.
|
|
12
|
+
* - an old build folder gets the one "build type changed" line.
|
|
13
|
+
* - clean-core and upgrade folders: nothing at all, as today.
|
|
14
|
+
*
|
|
15
|
+
* Runs at start-up only in an interactive run, after the seat is settled.
|
|
16
|
+
* Never throws: a problem is one line, and CSPeach starts as usual.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { TEAM_FILE, activeProject, readProjectFile, } from '../register/store.js';
|
|
21
|
+
import { LEGACY_BUILD_LINE, createBuildProject, isBuildProject, isLegacyBuildFolder } from '../commands/team-build.js';
|
|
22
|
+
import { runTeamCommand } from '../commands/team.js';
|
|
23
|
+
import { resolveProjectFolderSync } from '../projects/project-folder.js';
|
|
24
|
+
import { normDir } from '../session/project-match.js';
|
|
25
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
26
|
+
import { writeWhole } from '../register/write-whole.js';
|
|
27
|
+
export const SETUP_LINE = 'Set up as a team build folder. Your specs save here for the developer.';
|
|
28
|
+
export const FILES_QUESTION = 'This folder already has files in it. Set it up as a team build folder?';
|
|
29
|
+
export const NOT_SET_UP_LINE = 'Not set up. Start CSPeach in an empty folder for the team build.';
|
|
30
|
+
export const DEVELOPER_OFFER = 'Set this folder up as a team build (one shared folder for the spec and the build)?';
|
|
31
|
+
export const joinedLine = (name, lead) => `Joined the team build "${name}" (lead ${lead}).`;
|
|
32
|
+
/** What CSPeach itself leaves in a folder: none of it means "this folder holds files". */
|
|
33
|
+
const OURS = new Set(['.cspeach', 'cspeach-out', '.git', '.gitignore']);
|
|
34
|
+
export function folderState(cwd) {
|
|
35
|
+
const dir = path.resolve(cwd);
|
|
36
|
+
if (!fs.existsSync(path.join(dir, TEAM_FILE))) {
|
|
37
|
+
let entries = [];
|
|
38
|
+
try {
|
|
39
|
+
entries = fs.readdirSync(dir);
|
|
40
|
+
}
|
|
41
|
+
catch { /* unreadable folder: treat as empty of files */ }
|
|
42
|
+
return { kind: 'not-team', hasFiles: entries.some((e) => !OURS.has(e)), isHome: resolveProjectFolderSync(dir).kind === 'home' };
|
|
43
|
+
}
|
|
44
|
+
let project;
|
|
45
|
+
try {
|
|
46
|
+
project = readProjectFile(dir);
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
return { kind: 'unreadable', line: e instanceof Error ? e.message : String(e) };
|
|
50
|
+
}
|
|
51
|
+
if (isBuildProject(project)) {
|
|
52
|
+
const joined = activeProject();
|
|
53
|
+
return { kind: 'build', project, joined: !!joined && joined.projectId === project.projectId && normDir(joined.projectDir) === normDir(dir) };
|
|
54
|
+
}
|
|
55
|
+
if (isLegacyBuildFolder(project))
|
|
56
|
+
return { kind: 'legacy-build' };
|
|
57
|
+
return { kind: 'other-team' };
|
|
58
|
+
}
|
|
59
|
+
const offersFile = () => path.join(cspeachRoot(), 'team-build-offers.json');
|
|
60
|
+
function readOffered() {
|
|
61
|
+
try {
|
|
62
|
+
const raw = fs.readFileSync(offersFile(), 'utf8');
|
|
63
|
+
const j = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
64
|
+
return Array.isArray(j?.offered) ? j.offered.filter((x) => typeof x === 'string') : [];
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** `<CSPEACH_HOME>/team-build-offers.json`: `{ offered: [normDir, …] }`. */
|
|
71
|
+
export function fileOfferMemory() {
|
|
72
|
+
return {
|
|
73
|
+
wasOffered: async (dir) => readOffered().includes(normDir(dir)),
|
|
74
|
+
markOffered: async (dir) => {
|
|
75
|
+
const offered = readOffered();
|
|
76
|
+
const k = normDir(dir);
|
|
77
|
+
if (offered.includes(k))
|
|
78
|
+
return;
|
|
79
|
+
writeWhole(offersFile(), `${JSON.stringify({ offered: [...offered, k] }, null, 2)}\n`);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Ruling R10: CSPeach joins a team build by itself only when no project is
|
|
85
|
+
* active, or the active one is a team build too. A clean-core or upgrade
|
|
86
|
+
* project this laptop works on stays the active one: the journey and the team
|
|
87
|
+
* tools read `activeProject()`. An active project we cannot read is left
|
|
88
|
+
* alone as well.
|
|
89
|
+
*/
|
|
90
|
+
export function mayAutoJoin() {
|
|
91
|
+
try {
|
|
92
|
+
const active = activeProject();
|
|
93
|
+
if (!active)
|
|
94
|
+
return true;
|
|
95
|
+
return isBuildProject(readProjectFile(active.projectDir));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Join through the same route as `cspeach team join`, quietly. Failure: its own line(s). */
|
|
102
|
+
async function joinHere(dir, a) {
|
|
103
|
+
const said = [];
|
|
104
|
+
const deps = {
|
|
105
|
+
print: (l) => said.push(l),
|
|
106
|
+
identity: () => ({ name: a.identity.name }),
|
|
107
|
+
account: () => a.identity.account,
|
|
108
|
+
...(a.now ? { now: a.now } : {}),
|
|
109
|
+
};
|
|
110
|
+
const code = await runTeamCommand(['join', dir, '--outbox', dir], deps);
|
|
111
|
+
if (code !== 0)
|
|
112
|
+
for (const l of said)
|
|
113
|
+
a.emit(l);
|
|
114
|
+
return code === 0;
|
|
115
|
+
}
|
|
116
|
+
async function setUp(dir, a) {
|
|
117
|
+
const lead = a.identity.account ? { name: a.identity.name, account: a.identity.account } : { name: a.identity.name };
|
|
118
|
+
const project = createBuildProject(dir, {
|
|
119
|
+
name: path.basename(dir) || 'Team build',
|
|
120
|
+
lead,
|
|
121
|
+
now: (a.now ?? (() => new Date()))(),
|
|
122
|
+
...(a.rand ? { rand: a.rand } : {}),
|
|
123
|
+
});
|
|
124
|
+
if (mayAutoJoin() && !(await joinHere(dir, a)))
|
|
125
|
+
return null;
|
|
126
|
+
a.emit(SETUP_LINE);
|
|
127
|
+
return project;
|
|
128
|
+
}
|
|
129
|
+
export async function startTeamBuildFolder(a) {
|
|
130
|
+
const dir = path.resolve(a.cwd);
|
|
131
|
+
try {
|
|
132
|
+
const state = folderState(dir);
|
|
133
|
+
switch (state.kind) {
|
|
134
|
+
case 'other-team': return { project: null };
|
|
135
|
+
case 'unreadable':
|
|
136
|
+
a.emit(state.line);
|
|
137
|
+
return { project: null };
|
|
138
|
+
case 'legacy-build':
|
|
139
|
+
a.emit(LEGACY_BUILD_LINE);
|
|
140
|
+
return { project: null };
|
|
141
|
+
case 'build': {
|
|
142
|
+
if (state.joined || !mayAutoJoin())
|
|
143
|
+
return { project: state.project };
|
|
144
|
+
if (!(await joinHere(dir, a)))
|
|
145
|
+
return { project: null };
|
|
146
|
+
a.emit(joinedLine(state.project.name, state.project.lead.name));
|
|
147
|
+
return { project: state.project };
|
|
148
|
+
}
|
|
149
|
+
case 'not-team': {
|
|
150
|
+
if (a.seat === 'functional') {
|
|
151
|
+
// The home folder is never a team build: it is where CSPeach keeps its own files.
|
|
152
|
+
if (state.isHome) {
|
|
153
|
+
a.emit(NOT_SET_UP_LINE);
|
|
154
|
+
return { project: null };
|
|
155
|
+
}
|
|
156
|
+
if (state.hasFiles && (await a.confirm(FILES_QUESTION, { default: 'n' })) !== 'y') {
|
|
157
|
+
a.emit(NOT_SET_UP_LINE);
|
|
158
|
+
return { project: null };
|
|
159
|
+
}
|
|
160
|
+
return { project: await setUp(dir, a) };
|
|
161
|
+
}
|
|
162
|
+
if (state.isHome || (await a.memory.wasOffered(dir)))
|
|
163
|
+
return { project: null };
|
|
164
|
+
const answer = await a.confirm(DEVELOPER_OFFER, { default: 'n' });
|
|
165
|
+
try {
|
|
166
|
+
await a.memory.markOffered(dir);
|
|
167
|
+
}
|
|
168
|
+
catch { /* a failed offer write says nothing */ }
|
|
169
|
+
if (answer !== 'y')
|
|
170
|
+
return { project: null };
|
|
171
|
+
return { project: await setUp(dir, a) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
a.emit(e instanceof Error ? e.message.split('\n')[0] : String(e));
|
|
177
|
+
return { project: null };
|
|
178
|
+
}
|
|
179
|
+
}
|
package/dist/repl.js
CHANGED
|
@@ -14,7 +14,7 @@ import { startEarlyBrand, aliasSourceNote, panelsHintLine, panelsResumeLine, pan
|
|
|
14
14
|
import { formatAttention, formatFailure, formatInfo } from './renderer/notice-shape.js';
|
|
15
15
|
import { formatPanel } from './renderer/panel-rows.js';
|
|
16
16
|
import { sanitizeControlChars } from './renderer/sanitize.js';
|
|
17
|
-
import { formatRoutingLine } from './renderer/routing-line.js';
|
|
17
|
+
import { formatRoutingLine, typedSkillLine } from './renderer/routing-line.js';
|
|
18
18
|
import { handleUiLook, handleUiRendering } from './repl/ui-look-command.js';
|
|
19
19
|
import { handleStyleCommand, pickStyleClassic, pickStyleInk } from './repl/style-command.js';
|
|
20
20
|
import { handleLevelCommand, pickLevelClassic, pickLevelInk } from './repl/level-command.js';
|
|
@@ -39,9 +39,11 @@ import { getStore } from './auth/api-key.js';
|
|
|
39
39
|
import { readAuthFile } from './auth/auth-file.js';
|
|
40
40
|
import { resolveApiKey } from './auth/key-resolver.js';
|
|
41
41
|
import { fetchMe } from './auth/me.js';
|
|
42
|
+
import { resolveSeatKind, readSeatRecord, writeSeatRecord, seatSignInMessage, isLegacySignIn, READ_ONLY_SEAT_LINE } from './licence/seat-kind.js';
|
|
43
|
+
import { setSeatKind, isFunctionalSeat, getSeatKind } from './licence/seat-state.js';
|
|
42
44
|
import { runLicenceCheck, licenceCheckHeaders } from './licence/licence-check.js';
|
|
43
45
|
import { LicenceTurnGate } from './licence/turn-gate.js';
|
|
44
|
-
import { runStartupAccount } from './repl/startup-account.js';
|
|
46
|
+
import { runStartupAccount, settleSeatAfterStartup } from './repl/startup-account.js';
|
|
45
47
|
import { startSkillsCacheWarmup } from './skills/source-authed.js';
|
|
46
48
|
import { formatLoginBanner, signInFragment } from './ui/login-banner.js';
|
|
47
49
|
import { printStartupBanner, printKeytarFallbackBanner, keychainFallbackNotice } from './renderer/banners.js';
|
|
@@ -70,6 +72,7 @@ import { createProviderForMode } from './agent/providers/factory.js';
|
|
|
70
72
|
import { startUsageFlushAtStartup } from './usage/usage-reporter.js';
|
|
71
73
|
import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
|
|
72
74
|
import { runTurn } from './agent/loop.js';
|
|
75
|
+
import { isReadyForDeveloperPhrase } from './repl/ready-phrase.js';
|
|
73
76
|
import { IdleKeepalive } from './agent/cache-keepalive.js';
|
|
74
77
|
import { renderTurnError } from './agent/turn-error-ux.js';
|
|
75
78
|
import { runRerouteTurn } from './repl/reroute-turn.js';
|
|
@@ -453,6 +456,9 @@ export async function runRepl(opts) {
|
|
|
453
456
|
let signedInAs;
|
|
454
457
|
const showProfile = (profile) => {
|
|
455
458
|
signedInAs = profile.email;
|
|
459
|
+
// S2 — remember the account so a team build names this person by it.
|
|
460
|
+
if (profile.email)
|
|
461
|
+
void import('./licence/account-name.js').then((m) => m.rememberAccount(profile.email)).catch(() => { });
|
|
456
462
|
const banner = formatLoginBanner(profile);
|
|
457
463
|
if (getLook() === 'panels') {
|
|
458
464
|
signIn = signInFragment(profile);
|
|
@@ -469,6 +475,8 @@ export async function runRepl(opts) {
|
|
|
469
475
|
loadConfig, readAuthFile, resolveApiKey: () => resolveApiKey(), fetchMe, runLicenceCheck, licenceCheckHeaders,
|
|
470
476
|
// Final review finding 1 — fill the skills cache for offline use. Fire-and-forget.
|
|
471
477
|
startSkillsWarmup: (c, verdict) => { void startSkillsCacheWarmup(c, verdict); },
|
|
478
|
+
// Team build Task 5 — the seat kind for this run.
|
|
479
|
+
readSeatRecord, writeSeatRecord, setSeatKind,
|
|
472
480
|
}, {
|
|
473
481
|
print: (line) => console.log(line),
|
|
474
482
|
notice: (note) => {
|
|
@@ -496,6 +504,7 @@ export async function runRepl(opts) {
|
|
|
496
504
|
else
|
|
497
505
|
printKeytarFallbackBanner();
|
|
498
506
|
}
|
|
507
|
+
let seat = startupAccount.seat ?? 'developer';
|
|
499
508
|
if (!apiKey) {
|
|
500
509
|
const entered = await promptPassword({
|
|
501
510
|
message: 'Paste your CSPeach API key (from invite email):',
|
|
@@ -504,6 +513,17 @@ export async function runRepl(opts) {
|
|
|
504
513
|
await store.set(entered);
|
|
505
514
|
apiKey = entered;
|
|
506
515
|
}
|
|
516
|
+
// Team build Task 5, ruling R7a — a key and no seat yet (managed without
|
|
517
|
+
// auth.json: a stored key, or one pasted just now): settle it here. Only
|
|
518
|
+
// when start-up resolved nothing, so it never repeats start-up's /v1/me.
|
|
519
|
+
if (startupAccount.seat === undefined && apiKey) {
|
|
520
|
+
const settled = await settleSeatAfterStartup({ fetchMe, readAuthFile, readSeatRecord, writeSeatRecord, setSeatKind }, { proxyUrl: (await loadConfig()).proxy_url, apiKey });
|
|
521
|
+
if (settled === null) {
|
|
522
|
+
console.log(seatSignInMessage());
|
|
523
|
+
return 1;
|
|
524
|
+
}
|
|
525
|
+
seat = settled;
|
|
526
|
+
}
|
|
507
527
|
let cfg = await loadConfig();
|
|
508
528
|
setLook(cfg.ui.look);
|
|
509
529
|
// Chat lane (spec 2026-10-17 D1/D2/D5/D9) — the process lane and talking
|
|
@@ -515,6 +535,10 @@ export async function runRepl(opts) {
|
|
|
515
535
|
applyLaneTools(laneSetting, !!process.stdin.isTTY);
|
|
516
536
|
setAssistantStyle(cfg.assistant.style);
|
|
517
537
|
setAssistantLevel(cfg.assistant.level);
|
|
538
|
+
// Team build Task 5 — the developer default follows a functional seat; an
|
|
539
|
+
// explicit level stays, and /level still works for the session.
|
|
540
|
+
if (seat === 'functional' && cfg.assistant.level === 'developer')
|
|
541
|
+
setAssistantLevel('functional');
|
|
518
542
|
// Apply the rendering mode BEFORE any shouldUseInk() call. This is the
|
|
519
543
|
// interactive entry point, so it is never one-shot whatever argv holds. A
|
|
520
544
|
// stored `classic` is retiring: read as auto, rewritten once, one dim notice
|
|
@@ -670,7 +694,18 @@ export async function runRepl(opts) {
|
|
|
670
694
|
// The config hint only makes sense when config CAUSED advisory.
|
|
671
695
|
next: capped === '' ? 'cspeach config set write_mode approval-gated' : undefined,
|
|
672
696
|
});
|
|
673
|
-
|
|
697
|
+
// Team build Task 6 — a functional seat gets the seat line once, instead of
|
|
698
|
+
// the write-mode banner and its `cspeach config set` hint (config cannot
|
|
699
|
+
// lift it); both the no-role path here and the role path below skip it.
|
|
700
|
+
const functionalSeat = isFunctionalSeat();
|
|
701
|
+
if (functionalSeat) {
|
|
702
|
+
const seatLine = formatInfo(READ_ONLY_SEAT_LINE);
|
|
703
|
+
if (getLook() === 'panels')
|
|
704
|
+
startupNotices.push(seatLine);
|
|
705
|
+
else
|
|
706
|
+
console.log(seatLine);
|
|
707
|
+
}
|
|
708
|
+
else if (!anyRoleConfigured) {
|
|
674
709
|
if (cfg.write_mode === 'advisory-only') {
|
|
675
710
|
if (getLook() === 'panels')
|
|
676
711
|
startupNotices.push(advisoryNotice(''));
|
|
@@ -747,7 +782,7 @@ export async function runRepl(opts) {
|
|
|
747
782
|
// byte-identical to pre-role behavior).
|
|
748
783
|
const aliasRole = cfg.sap[selectedAlias]?.role;
|
|
749
784
|
const startupWriteMode = getEffectiveWriteMode(cfg.write_mode, aliasRole);
|
|
750
|
-
if (anyRoleConfigured) {
|
|
785
|
+
if (anyRoleConfigured && !functionalSeat) {
|
|
751
786
|
const capped = startupWriteMode !== cfg.write_mode
|
|
752
787
|
? ` (capped by sap.${selectedAlias}.role = ${aliasRole})`
|
|
753
788
|
: '';
|
|
@@ -871,6 +906,24 @@ export async function runRepl(opts) {
|
|
|
871
906
|
// No start-up check ran (no key yet, e.g. pasted at the prompt below):
|
|
872
907
|
// non-managed checks on the very first turn. Managed never reads it.
|
|
873
908
|
lastCheckAtMs: licenceCheckedAtMs ?? (cfg.llm.mode === 'managed' ? Date.now() : -Infinity),
|
|
909
|
+
// Team build Task 5 — the seat refreshes with the licence every 4 hours: a
|
|
910
|
+
// fresh answer wins (a developer→functional flip applies at the next
|
|
911
|
+
// turn; a fresh developer lifts a stale functional lock), else the stored
|
|
912
|
+
// record. An unconfirmed seat mid-session changes nothing.
|
|
913
|
+
onSeat: async (d) => {
|
|
914
|
+
const key = await store.get();
|
|
915
|
+
if (!key)
|
|
916
|
+
return;
|
|
917
|
+
const r = resolveSeatKind({
|
|
918
|
+
fresh: d.allow && d.verdict === 'ok' && d.profile ? { ok: true, profile: d.profile, serverDate: null } : null,
|
|
919
|
+
stored: await readSeatRecord(), apiKey: key, now: new Date(), legacySignIn: isLegacySignIn(await readAuthFile()),
|
|
920
|
+
});
|
|
921
|
+
if (!r.ok)
|
|
922
|
+
return;
|
|
923
|
+
if (r.write)
|
|
924
|
+
await writeSeatRecord(r.write).catch(() => { });
|
|
925
|
+
setSeatKind(r.kind);
|
|
926
|
+
},
|
|
874
927
|
...(cfg.llm.mode === 'azure' ? {
|
|
875
928
|
extra: async () => (await import('./connection/azure-gate.js')).azureGateCheck(),
|
|
876
929
|
// Plan D13 — never a prompt between turns (no inquirer inside the Ink
|
|
@@ -1326,6 +1379,9 @@ export async function runRepl(opts) {
|
|
|
1326
1379
|
if (result.kind === 'command') {
|
|
1327
1380
|
chunkEmitter.emit('chunk', formatInfo(result.command));
|
|
1328
1381
|
}
|
|
1382
|
+
else if (result.kind === 'info') {
|
|
1383
|
+
chunkEmitter.emit('chunk', result.text + '\n');
|
|
1384
|
+
}
|
|
1329
1385
|
else if (result.kind === 'empty') {
|
|
1330
1386
|
// Empty workspace — print the listing so the user sees the
|
|
1331
1387
|
// workspace path and the "drop something here" hint. Skipped
|
|
@@ -1529,6 +1585,42 @@ export async function runRepl(opts) {
|
|
|
1529
1585
|
effectiveInput = expansion.text;
|
|
1530
1586
|
}
|
|
1531
1587
|
}
|
|
1588
|
+
// Team build Task 10 — "this is ready for the developer" in a team build
|
|
1589
|
+
// folder marks the spec ready for build; no model turn. The phrase is
|
|
1590
|
+
// checked first (no file read); anything else goes on exactly as before.
|
|
1591
|
+
if (isReadyForDeveloperPhrase(trimmed)) {
|
|
1592
|
+
const { readyForDeveloperDoor, inkSpecChooser } = await import('./repl/ready-phrase.js');
|
|
1593
|
+
const { getAuthorIdentity } = await import('./agent/loop.js');
|
|
1594
|
+
if (await readyForDeveloperDoor(trimmed, {
|
|
1595
|
+
cwd: process.cwd(), identityName: getAuthorIdentity().name,
|
|
1596
|
+
emit: (line) => chunkEmitter.emit('chunk', line + '\n'), choose: inkSpecChooser(),
|
|
1597
|
+
}))
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
// Team build Task 11 — a shared spec's open questions travel with it
|
|
1601
|
+
// into the build. Anything not under the shared specs/ goes on unchanged.
|
|
1602
|
+
// F10 — a typed `/abap-app @<saved spec-gap>` goes through the gate too.
|
|
1603
|
+
if (/^\/abap-app\s+(?:--from\b|@)/.test(effectiveInput)) {
|
|
1604
|
+
try {
|
|
1605
|
+
const { openQuestionsGate, gateChooser, STOPPED_LINE } = await import('./repl/open-questions-gate.js');
|
|
1606
|
+
const { inkStartupChooser } = await import('./commands/plan-startup.js');
|
|
1607
|
+
const { getAuthorIdentity, inkAwareChoose, inkAwarePrompt } = await import('./agent/loop.js');
|
|
1608
|
+
const g = await openQuestionsGate({
|
|
1609
|
+
cwd: process.cwd(), command: effectiveInput, identity: getAuthorIdentity(),
|
|
1610
|
+
emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
|
|
1611
|
+
choose: gateChooser(inkStartupChooser()), answer: inkAwareChoose, prompt: inkAwarePrompt, headless: isHeadless(),
|
|
1612
|
+
});
|
|
1613
|
+
if (g && 'stop' in g) {
|
|
1614
|
+
chunkEmitter.emit('chunk', STOPPED_LINE + '\n');
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
if (g)
|
|
1618
|
+
effectiveInput = g.command;
|
|
1619
|
+
}
|
|
1620
|
+
catch (e) {
|
|
1621
|
+
chunkEmitter.emit('chunk', `${(e instanceof Error ? e.message : String(e)).split('\n')[0]}\n`);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1532
1624
|
// Guided runs — the one front door for Ink, classic and one-shot:
|
|
1533
1625
|
// the /run verb (rewritten to its alias), then today's trigger. The
|
|
1534
1626
|
// trigger still decides (spec §4 step 2); the dispatch below is unchanged.
|
|
@@ -2111,6 +2203,10 @@ export async function runRepl(opts) {
|
|
|
2111
2203
|
}
|
|
2112
2204
|
// 2026-10-04 — the phase's expert skill rides along in the system prompt.
|
|
2113
2205
|
const planPhaseDelegate = planPhaseCtx?.nextDelegateTo;
|
|
2206
|
+
// F6 — a slash command the person typed says which skill started.
|
|
2207
|
+
const typedLine = typedSkillLine({ skill: parsed.skill, queued: !!submitOpts?.queued, planPhase: !!planPhaseCtx });
|
|
2208
|
+
if (typedLine)
|
|
2209
|
+
chunkEmitter.emit('chunk', typedLine);
|
|
2114
2210
|
lastTurnBy = submitOpts?.queued?.by ?? 'person'; // Task 3
|
|
2115
2211
|
await runTurn({
|
|
2116
2212
|
provider,
|
|
@@ -2783,6 +2879,49 @@ export async function runRepl(opts) {
|
|
|
2783
2879
|
// sits in this folder. Best-effort: never blocks the prompt.
|
|
2784
2880
|
if (!isHeadless()) {
|
|
2785
2881
|
void (async () => {
|
|
2882
|
+
// F3 (2026-10-29 live run): wait until the screen listens. Without
|
|
2883
|
+
// this the team-build lines vanished and its cards resolved
|
|
2884
|
+
// "cancelled" before the screen had subscribed. Never hangs.
|
|
2885
|
+
try {
|
|
2886
|
+
const { whenInkReady } = await import('./ui/when-listening.js');
|
|
2887
|
+
await whenInkReady(chunkEmitter);
|
|
2888
|
+
}
|
|
2889
|
+
catch { /* best-effort: carry on as before */ }
|
|
2890
|
+
// Team build Task 8 — the folder sets itself up (spec 2026-10-25 §7.2):
|
|
2891
|
+
// first, so its question never overlaps the offers below. The seat is
|
|
2892
|
+
// settled by now (runStartupAccount / settleSeatAfterStartup above).
|
|
2893
|
+
try {
|
|
2894
|
+
const { startTeamBuildFolder, fileOfferMemory } = await import('./repl/team-build-start.js');
|
|
2895
|
+
const { inkAwareConfirm, getAuthorIdentity } = await import('./agent/loop.js');
|
|
2896
|
+
await startTeamBuildFolder({
|
|
2897
|
+
cwd: process.cwd(),
|
|
2898
|
+
seat: getSeatKind(),
|
|
2899
|
+
identity: { name: getAuthorIdentity().name, ...(signedInAs ? { account: signedInAs } : {}) },
|
|
2900
|
+
confirm: inkAwareConfirm,
|
|
2901
|
+
emit: (l) => chunkEmitter.emit('chunk', l + '\n'),
|
|
2902
|
+
memory: fileOfferMemory(),
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
catch { /* the folder step is best-effort */ }
|
|
2906
|
+
// Team build Task 11 — the start screen: the shared specs of the build
|
|
2907
|
+
// folder CSPeach runs in (R10), and the build offer (developers only).
|
|
2908
|
+
// A submitted build or question is the next turn: the offers below wait.
|
|
2909
|
+
try {
|
|
2910
|
+
const { runStartScreen } = await import('./repl/team-build-screen.js');
|
|
2911
|
+
const { inkStartupChooser } = await import('./commands/plan-startup.js');
|
|
2912
|
+
const { getAuthorIdentity } = await import('./agent/loop.js');
|
|
2913
|
+
const screen = await runStartScreen({
|
|
2914
|
+
cwd: process.cwd(),
|
|
2915
|
+
seat: getSeatKind(),
|
|
2916
|
+
identityName: getAuthorIdentity().name,
|
|
2917
|
+
choose: inkStartupChooser(),
|
|
2918
|
+
submit: (cmd) => { void handleSubmit(cmd); },
|
|
2919
|
+
emit: (l) => chunkEmitter.emit('chunk', l + '\n'),
|
|
2920
|
+
});
|
|
2921
|
+
if (screen.submitted)
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
catch { /* the start screen is best-effort */ }
|
|
2786
2925
|
// 2026-10-08 — one-time offer to retire the old `workspace_folder`
|
|
2787
2926
|
// setting, before the plan offer so the two never overlap.
|
|
2788
2927
|
try {
|
|
@@ -3112,16 +3251,50 @@ export async function runRepl(opts) {
|
|
|
3112
3251
|
}, { input: pasteInput });
|
|
3113
3252
|
// 2026-10-08: one-time offer to retire the old `workspace_folder` setting
|
|
3114
3253
|
// (files now save in the folder you start in). Best-effort, never blocks.
|
|
3254
|
+
// Team build Task 11 — a build or question the start screen queued is the
|
|
3255
|
+
// next line; the retire and plan offers then stay quiet.
|
|
3256
|
+
let teamBuildSubmitted = false;
|
|
3115
3257
|
if (!isHeadless()) {
|
|
3258
|
+
// Team build Task 8 — the folder step, first (same as the Ink path).
|
|
3116
3259
|
try {
|
|
3117
|
-
const {
|
|
3118
|
-
const { inkAwareConfirm } = await import('./agent/loop.js');
|
|
3119
|
-
await
|
|
3120
|
-
|
|
3121
|
-
|
|
3260
|
+
const { startTeamBuildFolder, fileOfferMemory } = await import('./repl/team-build-start.js');
|
|
3261
|
+
const { inkAwareConfirm, getAuthorIdentity } = await import('./agent/loop.js');
|
|
3262
|
+
await startTeamBuildFolder({
|
|
3263
|
+
cwd: process.cwd(),
|
|
3264
|
+
seat: getSeatKind(),
|
|
3265
|
+
identity: { name: getAuthorIdentity().name, ...(signedInAs ? { account: signedInAs } : {}) },
|
|
3266
|
+
confirm: inkAwareConfirm,
|
|
3267
|
+
emit: (l) => console.log(l),
|
|
3268
|
+
memory: fileOfferMemory(),
|
|
3122
3269
|
});
|
|
3123
3270
|
}
|
|
3124
3271
|
catch { /* best-effort */ }
|
|
3272
|
+
// Team build Task 11 — the start screen (same as the Ink path).
|
|
3273
|
+
try {
|
|
3274
|
+
const { runStartScreen } = await import('./repl/team-build-screen.js');
|
|
3275
|
+
const { classicStartupChooser } = await import('./commands/plan-startup.js');
|
|
3276
|
+
const { getAuthorIdentity, inkAwarePrompt } = await import('./agent/loop.js');
|
|
3277
|
+
const screen = await runStartScreen({
|
|
3278
|
+
cwd: process.cwd(),
|
|
3279
|
+
seat: getSeatKind(),
|
|
3280
|
+
identityName: getAuthorIdentity().name,
|
|
3281
|
+
choose: classicStartupChooser(inkAwarePrompt, (...l) => l.forEach((x) => console.log(x))),
|
|
3282
|
+
submit: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
3283
|
+
emit: (l) => console.log(l),
|
|
3284
|
+
});
|
|
3285
|
+
teamBuildSubmitted = screen.submitted;
|
|
3286
|
+
}
|
|
3287
|
+
catch { /* best-effort */ }
|
|
3288
|
+
if (!teamBuildSubmitted)
|
|
3289
|
+
try {
|
|
3290
|
+
const { maybeOfferWorkspaceRetire } = await import('./projects/workspace-retire.js');
|
|
3291
|
+
const { inkAwareConfirm } = await import('./agent/loop.js');
|
|
3292
|
+
await maybeOfferWorkspaceRetire({
|
|
3293
|
+
confirm: async (q) => (await inkAwareConfirm(q, { default: 'y' })) === 'y',
|
|
3294
|
+
log: (line) => console.log(chalk.dim(line)),
|
|
3295
|
+
});
|
|
3296
|
+
}
|
|
3297
|
+
catch { /* best-effort */ }
|
|
3125
3298
|
}
|
|
3126
3299
|
// 2026-06-15: one-time output-namespace migration. The customer-output
|
|
3127
3300
|
// root was renamed from the dead-brand `.abapforge/` to `.cspeach/`.
|
|
@@ -3146,7 +3319,7 @@ export async function runRepl(opts) {
|
|
|
3146
3319
|
}
|
|
3147
3320
|
// 2026-10-04 (spec §3.5) — "Continue '<plan>'?" (classic: numbered
|
|
3148
3321
|
// choices, Enter = the first). Continue queues the resume for the loop.
|
|
3149
|
-
if (!isHeadless()) {
|
|
3322
|
+
if (!isHeadless() && !teamBuildSubmitted) {
|
|
3150
3323
|
try {
|
|
3151
3324
|
const { offerContinueAtStartup, classicStartupChooser } = await import('./commands/plan-startup.js');
|
|
3152
3325
|
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
@@ -3460,6 +3633,9 @@ export async function runRepl(opts) {
|
|
|
3460
3633
|
console.log(formatInfo('Picked · press Enter to run, or edit first').trimEnd());
|
|
3461
3634
|
pendingPrefill = result.command;
|
|
3462
3635
|
}
|
|
3636
|
+
else if (result.kind === 'info') {
|
|
3637
|
+
console.log(result.text);
|
|
3638
|
+
}
|
|
3463
3639
|
else if (result.kind === 'empty') {
|
|
3464
3640
|
// Empty workspace — print the listing so the user sees the
|
|
3465
3641
|
// workspace path and the "drop something here" hint. Skipped
|
|
@@ -3740,6 +3916,9 @@ export async function runRepl(opts) {
|
|
|
3740
3916
|
// UX-5 #2 (2026-06-12) — numbered-menu expansion (classic mirror of the
|
|
3741
3917
|
// Ink block above). Bare `2` after a plain-text numbered menu expands
|
|
3742
3918
|
// to that item's text with a dim echo; everything else passes through.
|
|
3919
|
+
// Team build Task 10 — the ready door checks what was typed, before a
|
|
3920
|
+
// menu number is expanded (the same text as the Ink door's `trimmed`).
|
|
3921
|
+
const typedText = resolvedTrimmed;
|
|
3743
3922
|
{
|
|
3744
3923
|
const expansion = maybeExpandNumberSelection(resolvedTrimmed, lastAssistantText(session.messages));
|
|
3745
3924
|
if (expansion) {
|
|
@@ -3747,6 +3926,40 @@ export async function runRepl(opts) {
|
|
|
3747
3926
|
resolvedTrimmed = expansion.text;
|
|
3748
3927
|
}
|
|
3749
3928
|
}
|
|
3929
|
+
// Team build Task 10 — the ready phrase (classic mirror of the Ink door).
|
|
3930
|
+
if (isReadyForDeveloperPhrase(typedText)) {
|
|
3931
|
+
const { readyForDeveloperDoor, classicSpecChooser } = await import('./repl/ready-phrase.js');
|
|
3932
|
+
const { getAuthorIdentity, inkAwarePrompt } = await import('./agent/loop.js');
|
|
3933
|
+
if (await readyForDeveloperDoor(typedText, {
|
|
3934
|
+
cwd: process.cwd(), identityName: getAuthorIdentity().name,
|
|
3935
|
+
emit: (line) => console.log(line), choose: classicSpecChooser(inkAwarePrompt, (line) => console.log(line)),
|
|
3936
|
+
}))
|
|
3937
|
+
continue;
|
|
3938
|
+
}
|
|
3939
|
+
// Team build Task 11 — the open-questions gate (classic mirror of the Ink path).
|
|
3940
|
+
// F10 — a typed `/abap-app @<saved spec-gap>` goes through the gate too.
|
|
3941
|
+
if (/^\/abap-app\s+(?:--from\b|@)/.test(resolvedTrimmed)) {
|
|
3942
|
+
try {
|
|
3943
|
+
const { openQuestionsGate, gateChooser, STOPPED_LINE } = await import('./repl/open-questions-gate.js');
|
|
3944
|
+
const { classicStartupChooser } = await import('./commands/plan-startup.js');
|
|
3945
|
+
const { getAuthorIdentity, inkAwareChoose, inkAwarePrompt } = await import('./agent/loop.js');
|
|
3946
|
+
const g = await openQuestionsGate({
|
|
3947
|
+
cwd: process.cwd(), command: resolvedTrimmed, identity: getAuthorIdentity(),
|
|
3948
|
+
emit: (line) => console.log(line),
|
|
3949
|
+
choose: gateChooser(classicStartupChooser(inkAwarePrompt, (...l) => l.forEach((x) => console.log(x)))),
|
|
3950
|
+
answer: inkAwareChoose, prompt: inkAwarePrompt, headless: isHeadless(),
|
|
3951
|
+
});
|
|
3952
|
+
if (g && 'stop' in g) {
|
|
3953
|
+
console.log(STOPPED_LINE);
|
|
3954
|
+
continue;
|
|
3955
|
+
}
|
|
3956
|
+
if (g)
|
|
3957
|
+
resolvedTrimmed = g.command;
|
|
3958
|
+
}
|
|
3959
|
+
catch (e) {
|
|
3960
|
+
console.log((e instanceof Error ? e.message : String(e)).split('\n')[0]);
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3750
3963
|
// Guided runs — the same front door as the Ink path: the /run verb
|
|
3751
3964
|
// (rewritten to its alias), then today's trigger. The trigger still
|
|
3752
3965
|
// decides (spec §4 step 2); the dispatch below is unchanged.
|
|
@@ -4268,6 +4481,10 @@ export async function runRepl(opts) {
|
|
|
4268
4481
|
}
|
|
4269
4482
|
// 2026-10-04 — the phase's expert skill rides along in the system prompt.
|
|
4270
4483
|
const planPhaseDelegate = planPhaseCtx?.nextDelegateTo;
|
|
4484
|
+
// F6 — a slash command the person typed says which skill started.
|
|
4485
|
+
const typedLine = typedSkillLine({ skill: parsed.skill, queued: classicLineBy !== 'person', planPhase: !!planPhaseCtx });
|
|
4486
|
+
if (typedLine)
|
|
4487
|
+
console.log(typedLine.trimEnd());
|
|
4271
4488
|
lastTurnBy = classicLineBy; // Task 3
|
|
4272
4489
|
await runTurn({
|
|
4273
4490
|
provider,
|
package/dist/sap-link/policy.js
CHANGED
|
@@ -112,6 +112,8 @@ export function writesBlockedMessage(alias, role) {
|
|
|
112
112
|
const now = role === undefined ? 'has no role set' : `has role ${role}`;
|
|
113
113
|
return `Writes to ${alias} need role dev. ${alias} ${now}. Set it with: cspeach config set sap.${alias}.role dev`;
|
|
114
114
|
}
|
|
115
|
+
/** Team build (final review F3): the one line a read-only seat's link says, at start and on every refused write. */
|
|
116
|
+
export const READ_ONLY_LINK_LINE = 'Read-only seat: the SAP link reads and never writes.';
|
|
115
117
|
export function decideRequest(input) {
|
|
116
118
|
const m = effectiveMethod(input.method, input.headers);
|
|
117
119
|
if ('error' in m)
|
|
@@ -178,6 +180,8 @@ export function decideRequest(input) {
|
|
|
178
180
|
};
|
|
179
181
|
}
|
|
180
182
|
const write = verbIsWrite || isBatchPath(url.pathname);
|
|
183
|
+
if (write && input.seatReadOnly)
|
|
184
|
+
return { allow: false, status: 403, message: READ_ONLY_LINK_LINE };
|
|
181
185
|
if (write && input.role !== 'dev') {
|
|
182
186
|
return { allow: false, status: 403, message: writesBlockedMessage(input.alias, input.role) };
|
|
183
187
|
}
|
|
@@ -26,6 +26,8 @@ import { readLinkTargets } from './project.js';
|
|
|
26
26
|
import { startSapLink, SapLinkStartError } from './server.js';
|
|
27
27
|
import { UpstreamSession } from './upstream.js';
|
|
28
28
|
import { registerShutdownHook } from '../lib/shutdown-hooks.js';
|
|
29
|
+
import { isFunctionalSeat } from '../licence/seat-state.js';
|
|
30
|
+
import { READ_ONLY_LINK_LINE } from './policy.js';
|
|
29
31
|
/** A registry error. Its message is always a fixed, plain, secret-free sentence. */
|
|
30
32
|
export class SapLinkError extends Error {
|
|
31
33
|
}
|
|
@@ -113,6 +115,7 @@ async function ensureUnlocked(projectDir, alias, partial) {
|
|
|
113
115
|
password: (a, s) => resolveSapPassword(a, s, { headless: () => true }),
|
|
114
116
|
readCa: (p) => readFileSync(p),
|
|
115
117
|
start: startSapLink,
|
|
118
|
+
readOnlySeat: isFunctionalSeat,
|
|
116
119
|
...partial,
|
|
117
120
|
};
|
|
118
121
|
// package.json first: its errors are plain LinkConfigErrors and need no SAP settings.
|
|
@@ -134,7 +137,9 @@ async function ensureUnlocked(projectDir, alias, partial) {
|
|
|
134
137
|
await stopUnlocked(projectDir);
|
|
135
138
|
throw new SapLinkError(`No SAP system named ${alias}. Add it with: cspeach config add-sap`);
|
|
136
139
|
}
|
|
137
|
-
|
|
140
|
+
// A read-only seat: one line of its own; the role note would point at a setting that changes nothing.
|
|
141
|
+
const seatNote = deps.readOnlySeat() ? READ_ONLY_LINK_LINE : undefined;
|
|
142
|
+
const roleNote = seatNote ? undefined : roleNoteFor(alias, sys.role);
|
|
138
143
|
const fingerprint = fingerprintFor(sys);
|
|
139
144
|
const existing = links.get(k);
|
|
140
145
|
if (existing && existing.alias === alias && existing.fingerprint === fingerprint &&
|
|
@@ -142,7 +147,7 @@ async function ensureUnlocked(projectDir, alias, partial) {
|
|
|
142
147
|
// A link whose upstream got a 401 refuses every send: start means restart (new session, new password).
|
|
143
148
|
!existing.upstream.isLoginRejected()) {
|
|
144
149
|
// TLS/http notes describe the running link; the role is read fresh (the server does too).
|
|
145
|
-
return { handle: existing.handle, reused: true, services, ...existing.postureNotes, ...(roleNote ? { roleNote } : {}) };
|
|
150
|
+
return { handle: existing.handle, reused: true, services, ...existing.postureNotes, ...(roleNote ? { roleNote } : {}), ...(seatNote ? { seatNote } : {}) };
|
|
146
151
|
}
|
|
147
152
|
// Another alias, connection, auth type, TLS posture, port or service list, or a rejected login: the old link goes first.
|
|
148
153
|
if (existing)
|
|
@@ -199,6 +204,7 @@ async function ensureUnlocked(projectDir, alias, partial) {
|
|
|
199
204
|
upstream,
|
|
200
205
|
secret: { password, username: sys.username },
|
|
201
206
|
writeLogFile: writeLogFileFor(projectDir),
|
|
207
|
+
readOnlySeat: () => deps.readOnlySeat(),
|
|
202
208
|
});
|
|
203
209
|
}
|
|
204
210
|
catch (e) {
|
|
@@ -213,7 +219,7 @@ async function ensureUnlocked(projectDir, alias, partial) {
|
|
|
213
219
|
const postureNotes = postureNotesFor(alias, sys);
|
|
214
220
|
links.set(k, { projectDir: path.resolve(projectDir), alias, prefixes, requestedPort: targets.port, fingerprint, postureNotes, handle, upstream });
|
|
215
221
|
installSapLinkExitHandler();
|
|
216
|
-
return { handle, reused: false, services, ...postureNotes, ...(roleNote ? { roleNote } : {}) };
|
|
222
|
+
return { handle, reused: false, services, ...postureNotes, ...(roleNote ? { roleNote } : {}), ...(seatNote ? { seatNote } : {}) };
|
|
217
223
|
}
|
|
218
224
|
export function stopSapLink(projectDir) {
|
|
219
225
|
return serial(() => stopUnlocked(projectDir));
|
package/dist/sap-link/server.js
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* failed pass-through never says "OK".
|
|
40
40
|
*/
|
|
41
41
|
import * as http from 'node:http';
|
|
42
|
-
import { decideRequest, writesBlockedMessage } from './policy.js';
|
|
42
|
+
import { decideRequest, READ_ONLY_LINK_LINE, writesBlockedMessage } from './policy.js';
|
|
43
43
|
import { appendWriteRow } from './write-log.js';
|
|
44
44
|
export class SapLinkStartError extends Error {
|
|
45
45
|
code;
|
|
@@ -210,6 +210,7 @@ export async function startSapLink(opts) {
|
|
|
210
210
|
method: req.method ?? 'GET', rawUrl: req.url ?? '/',
|
|
211
211
|
headers: req.headers,
|
|
212
212
|
allowedPrefixes: opts.allowedPrefixes, alias: opts.alias, role,
|
|
213
|
+
...(opts.readOnlySeat?.() ? { seatReadOnly: true } : {}),
|
|
213
214
|
});
|
|
214
215
|
if (!d.allow)
|
|
215
216
|
return plain(res, d.status, d.message);
|
|
@@ -233,6 +234,8 @@ export async function startSapLink(opts) {
|
|
|
233
234
|
catch {
|
|
234
235
|
return plain(res, 503, SETTINGS_UNREADABLE);
|
|
235
236
|
}
|
|
237
|
+
if (opts.readOnlySeat?.())
|
|
238
|
+
return plain(res, 403, READ_ONLY_LINK_LINE);
|
|
236
239
|
if (again !== 'dev')
|
|
237
240
|
return plain(res, 403, writesBlockedMessage(opts.alias, again));
|
|
238
241
|
}
|