@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,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team build (spec 2026-10-25) — `cspeach team spec list|ready [<slug>]`.
|
|
3
|
+
*
|
|
4
|
+
* list every spec in this team build: status, title, author, date, open questions
|
|
5
|
+
* ready [<slug>] mark a draft "ready for build" for the developer
|
|
6
|
+
*
|
|
7
|
+
* The same marking runs from the plain phrase "this is ready for the
|
|
8
|
+
* developer" in the REPL (repl/ready-phrase.ts).
|
|
9
|
+
*
|
|
10
|
+
* Ruling R10: the build project is the folder CSPeach runs in, never
|
|
11
|
+
* `activeProject()` — a developer on a clean-core wave keeps that one active.
|
|
12
|
+
* Ruling R12: status belongs to the spec folder (slug), an added-only record
|
|
13
|
+
* in `specs/<slug>/status/`, not to one envelope version.
|
|
14
|
+
*
|
|
15
|
+
* Both seats run it: the functional authors the spec, the developer reads it.
|
|
16
|
+
* `spec` is neither a recording verb nor a clean-core-only verb.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { TEAM_FILE, readProjectFile } from '../register/store.js';
|
|
21
|
+
import { isBuildProject } from './team-build.js';
|
|
22
|
+
import { listSharedSpecs, specAuthorFor, writeStatusRecord } from '../projects/spec-share.js';
|
|
23
|
+
export const NOT_BUILD_SPEC_LINE = 'This folder is not a team build. "cspeach team spec" works in a team build folder.';
|
|
24
|
+
export const NO_DRAFT_LINE = 'No draft spec here yet. Work the requirement through spec gap first, then say when it is ready.';
|
|
25
|
+
export const markedReadyLine = (title) => `Marked ready for the developer: "${title}". They will see it when they open CSPeach in this folder.`;
|
|
26
|
+
const USAGE = 'Usage: cspeach team spec list|ready [<slug>]';
|
|
27
|
+
/**
|
|
28
|
+
* The team build in exactly this folder, or null. Cheap outside a team
|
|
29
|
+
* folder: one file-exists check. An unreadable project file is null too.
|
|
30
|
+
*/
|
|
31
|
+
export function buildFolderAt(cwd) {
|
|
32
|
+
const dir = path.resolve(cwd);
|
|
33
|
+
if (!fs.existsSync(path.join(dir, TEAM_FILE)))
|
|
34
|
+
return null;
|
|
35
|
+
try {
|
|
36
|
+
const project = readProjectFile(dir);
|
|
37
|
+
return isBuildProject(project) ? { dir, project } : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export const draftSpecs = (teamDir) => listSharedSpecs(teamDir).filter((s) => s.status === 'draft');
|
|
44
|
+
/** Add the "ready for build" record to the spec's folder. */
|
|
45
|
+
export function markSpecReady(spec, projectId, by, now, rand) {
|
|
46
|
+
return writeStatusRecord(spec.specDir, {
|
|
47
|
+
projectId, slug: spec.slug, file: path.basename(spec.envelopePath), status: 'ready for build', by, at: now.toISOString(),
|
|
48
|
+
}, rand);
|
|
49
|
+
}
|
|
50
|
+
export async function specCommand(args, deps, cwd = process.cwd()) {
|
|
51
|
+
const [verb, ...rest] = args;
|
|
52
|
+
const here = buildFolderAt(cwd);
|
|
53
|
+
if (!here) {
|
|
54
|
+
deps.print(NOT_BUILD_SPEC_LINE);
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
switch (verb) {
|
|
58
|
+
case 'list': {
|
|
59
|
+
const specs = listSharedSpecs(here.dir);
|
|
60
|
+
if (!specs.length) {
|
|
61
|
+
deps.print('No specs here yet.');
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
for (const s of specs) {
|
|
65
|
+
deps.print(`${s.status.padEnd(16)} ${s.title} (${s.author}, ${s.createdAt.slice(0, 10)}) — ${s.openQuestions.length} open`);
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
case 'ready': {
|
|
70
|
+
const slug = rest[0];
|
|
71
|
+
const all = listSharedSpecs(here.dir);
|
|
72
|
+
let spec;
|
|
73
|
+
if (slug) {
|
|
74
|
+
spec = all.find((s) => s.slug === slug);
|
|
75
|
+
if (!spec) {
|
|
76
|
+
deps.print(`No spec "${slug}" here. Specs: ${all.length ? all.map((s) => s.slug).join(', ') : 'none yet'}`);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
if (spec.status !== 'draft') {
|
|
80
|
+
deps.print(`"${spec.title}" is already ${spec.status}.`);
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const drafts = all.filter((s) => s.status === 'draft');
|
|
86
|
+
if (!drafts.length) {
|
|
87
|
+
deps.print(NO_DRAFT_LINE);
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
if (drafts.length > 1) {
|
|
91
|
+
deps.print('Say which: cspeach team spec ready <slug>');
|
|
92
|
+
for (const s of drafts)
|
|
93
|
+
deps.print(` ${s.slug} ${s.title}`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
spec = drafts[0];
|
|
97
|
+
}
|
|
98
|
+
const by = specAuthorFor(here.project.projectId, deps.identity().name);
|
|
99
|
+
markSpecReady(spec, here.project.projectId, by, (deps.now ?? (() => new Date()))(), deps.rand);
|
|
100
|
+
deps.print(markedReadyLine(spec.title));
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
default:
|
|
104
|
+
deps.print(USAGE);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
package/dist/commands/team.js
CHANGED
|
@@ -23,7 +23,7 @@ import os from 'node:os';
|
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { decidedCount, decisionWhatOf, isAppLabel, isPatternWord, laneIdFor, levelOf, normalizeRuleId, objectKey, partCounts, proposeLanes, scopeFromWords, targetsOf, viewWords, } from '@cspeach/register-core';
|
|
25
25
|
import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.js';
|
|
26
|
-
import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, readEstate, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
|
|
26
|
+
import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, coreProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, readEstate, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
|
|
27
27
|
import { MAX_API_DOCUMENTS, MAX_SECOND_PASS, measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
|
|
28
28
|
import { measureFromFile, shown, shownUpTo, upToFive } from '../register/atc-file.js';
|
|
29
29
|
import { writeLeadRecord, writeRecord } from '../register/records.js';
|
|
@@ -39,7 +39,11 @@ import { lookupVariant, variantLookupLine } from '../register/variant.js';
|
|
|
39
39
|
import { TRANSPORT_NONE_WORDS, TRANSPORT_REQUEST, notARequestLine, projectTransportWords, writeProjectTransport } from '../register/project-transport.js';
|
|
40
40
|
import { teamDiscover, evidenceRows, EVIDENCE_LINE, OUTSIDE_LINE } from './team-discover.js';
|
|
41
41
|
import { rulesCommand } from './team-rules.js';
|
|
42
|
+
import { specCommand } from './team-spec.js';
|
|
42
43
|
import { focusCommand, laneChangeRecord, laneProposal, nowLine, planCommand, priorityCommand, reasonFrom } from './team-plan.js';
|
|
44
|
+
import { CLEAN_CORE_ONLY_VERBS, FUNCTIONAL_VERB_LINE, buildJoinLine, buildVerbRefusal, createBuildProject, isBuildProject, isRecordingVerb, } from './team-build.js';
|
|
45
|
+
import { seatSignInMessage } from '../licence/seat-kind.js';
|
|
46
|
+
import { getSeatKind } from '../licence/seat-state.js';
|
|
43
47
|
const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
|
|
44
48
|
const TYPES = ['clean-core', 'upgrade', 'build'];
|
|
45
49
|
/**
|
|
@@ -125,9 +129,60 @@ function matches(pattern, value) {
|
|
|
125
129
|
const re = new RegExp(`^${pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i');
|
|
126
130
|
return re.test(value);
|
|
127
131
|
}
|
|
132
|
+
/** Verbs that choose or leave a project themselves: no folder note. */
|
|
133
|
+
const FOLDER_NOTE_SKIP = new Set(['init', 'start', 'join', 'leave', 'help', '--help', '-h']);
|
|
134
|
+
/**
|
|
135
|
+
* F11 — one line when `cwd` holds a team project other than the active one.
|
|
136
|
+
* Null when it matches, when the folder is no team project, or on any read
|
|
137
|
+
* problem (the verb then runs exactly as before).
|
|
138
|
+
*/
|
|
139
|
+
export function folderMismatchLine(cwd) {
|
|
140
|
+
try {
|
|
141
|
+
if (!fs.existsSync(path.join(cwd, TEAM_FILE)))
|
|
142
|
+
return null;
|
|
143
|
+
const here = readProjectFile(cwd);
|
|
144
|
+
const active = activeProject();
|
|
145
|
+
if (!active || active.projectId === here.projectId)
|
|
146
|
+
return null;
|
|
147
|
+
const activeName = readProjectFile(active.projectDir).name;
|
|
148
|
+
return `This folder is the team project "${here.name}", but CSPeach is recording against "${activeName}". To work on "${here.name}": cspeach team join .`;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
128
154
|
export async function runTeamCommand(args, deps) {
|
|
129
155
|
const [sub, ...rest] = args;
|
|
130
156
|
try {
|
|
157
|
+
// Team build (spec §7.2): a functional seat reads a team project and never
|
|
158
|
+
// records work into it. Read-only verbs never ask for the seat. On by
|
|
159
|
+
// default (final review F1): without deps.seat the process seat decides
|
|
160
|
+
// (seat-state.ts — developer unless start-up resolved functional), so a
|
|
161
|
+
// caller that builds its own TeamDeps (the clean-core journey) is held too.
|
|
162
|
+
if (isRecordingVerb(sub, rest)) {
|
|
163
|
+
const seat = await (deps.seat ?? (async () => getSeatKind()))();
|
|
164
|
+
if (seat === 'functional') {
|
|
165
|
+
deps.print(FUNCTIONAL_VERB_LINE);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
if (seat === 'unconfirmed') {
|
|
169
|
+
deps.print(seatSignInMessage());
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// F11 (2026-10-29 live run): verbs follow the active project (R10). When
|
|
174
|
+
// the folder they run in is a different team project, say so first — a
|
|
175
|
+
// status in the ZTEST_LAS folder silently reported another project.
|
|
176
|
+
if (sub && !FOLDER_NOTE_SKIP.has(sub)) {
|
|
177
|
+
const note = folderMismatchLine((deps.cwd ?? (() => process.cwd()))());
|
|
178
|
+
if (note)
|
|
179
|
+
deps.print(note);
|
|
180
|
+
}
|
|
181
|
+
// A team build folder has no baseline: the clean-core verbs are refused in one line.
|
|
182
|
+
if (sub && CLEAN_CORE_ONLY_VERBS.includes(sub) && activeIsBuild()) {
|
|
183
|
+
deps.print(buildVerbRefusal(sub));
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
131
186
|
switch (sub) {
|
|
132
187
|
case 'init': return await init(rest, deps);
|
|
133
188
|
case 'start': return await start(rest, deps);
|
|
@@ -151,8 +206,9 @@ export async function runTeamCommand(args, deps) {
|
|
|
151
206
|
case 'priority': return priorityCommand(rest, deps);
|
|
152
207
|
case 'plan': return await planCommand(rest, deps);
|
|
153
208
|
case 'rebuild': return await rebuildVerb(rest, deps);
|
|
209
|
+
case 'spec': return await specCommand(rest, deps);
|
|
154
210
|
default:
|
|
155
|
-
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|target|transport|fixed|sheet|report|export|discover|focus|priority|plan|start|rebuild|rules (cspeach --help for details)');
|
|
211
|
+
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|target|transport|fixed|sheet|report|export|discover|focus|priority|plan|start|rebuild|rules|spec (cspeach --help for details)');
|
|
156
212
|
return sub ? 1 : 0;
|
|
157
213
|
}
|
|
158
214
|
}
|
|
@@ -170,6 +226,28 @@ export async function runTeamCommand(args, deps) {
|
|
|
170
226
|
return 1;
|
|
171
227
|
}
|
|
172
228
|
}
|
|
229
|
+
/** The joined project is a team build. Unreadable or none: no guard (the verb says why itself). */
|
|
230
|
+
function activeIsBuild() {
|
|
231
|
+
try {
|
|
232
|
+
const joined = activeProject();
|
|
233
|
+
return !!joined && isBuildProject(readProjectFile(joined.projectDir));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The team build folder (Task 7): no SAP connection, nothing pulled or
|
|
241
|
+
* measured. The lead joins with the outbox in the folder itself.
|
|
242
|
+
*/
|
|
243
|
+
function initBuild(dir, name, lead, deps, asName) {
|
|
244
|
+
createBuildProject(dir, { name, lead: { name: lead }, now: (deps.now ?? (() => new Date()))(), ...(deps.rand ? { rand: deps.rand } : {}) });
|
|
245
|
+
const quiet = { ...deps, print: () => { } };
|
|
246
|
+
join([dir, '--outbox', dir], quiet, asName);
|
|
247
|
+
deps.print(`✓ Team build "${name}" set up in this folder. Lead: ${lead}.`);
|
|
248
|
+
deps.print(' Specs save to specs/ for the developer. Start CSPeach here: cspeach');
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
173
251
|
/** True when this seat is the project's lead. The one test: requireLead and team_lane_move both use it. */
|
|
174
252
|
export function isLead(project, deps) {
|
|
175
253
|
return whoAmI(deps).trim().toLowerCase() === project.lead.name.trim().toLowerCase();
|
|
@@ -191,6 +269,8 @@ const INIT_USAGE = 'Usage: cspeach team init <folder> --name "<name>" [--lead "<
|
|
|
191
269
|
+ '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure] [--no-from-discovery]';
|
|
192
270
|
/** Everything `init` understands. Anything else is a typo, and a typo is refused, never ignored. */
|
|
193
271
|
const INIT_OPTIONS = ['name', 'lead', 'type', 'packages', 'include-local', 'sap-alias', 'atc-variant', 'no-measure', 'from-discovery', 'no-from-discovery'];
|
|
272
|
+
/** What `init --type build` takes: a team build has no packages, no check and no discovery to read. */
|
|
273
|
+
const INIT_BUILD_OPTIONS = ['name', 'lead', 'type'];
|
|
194
274
|
/** The two that take no value, so neither can eat the folder name. */
|
|
195
275
|
const INIT_FLAGS = ['include-local', 'no-measure', 'from-discovery', 'no-from-discovery'];
|
|
196
276
|
/** Typed on `init` out of muscle memory: they choose what to MEASURE, which is another verb. */
|
|
@@ -220,7 +300,9 @@ function unknownInitOption(name) {
|
|
|
220
300
|
}
|
|
221
301
|
const START_USAGE = 'Usage: cspeach team start <packages> | --for "<area words, e.g. FI and SD>" [--developers "<names>, me"] '
|
|
222
302
|
+ '[--name "<name>"] [--atc-variant <NAME>] [--transport <REQUEST>] [--folder <folder>] [--no-measure] [--sap-alias <name>]';
|
|
223
|
-
const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias', 'for', 'developers', 'transport'];
|
|
303
|
+
const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias', 'for', 'developers', 'transport', 'type'];
|
|
304
|
+
/** What `start --type build` takes besides the type: a team build has no packages, no check and no transport yet. */
|
|
305
|
+
const START_BUILD_OPTIONS = ['type', 'name', 'folder'];
|
|
224
306
|
/** The check a project measures with when nobody names one. */
|
|
225
307
|
export const DEFAULT_ATC_VARIANT = 'ABAP_CLOUD_READINESS';
|
|
226
308
|
/** I16: `--for` reads the packages from the folder's discovery; without one there is nothing to read them from. */
|
|
@@ -288,6 +370,13 @@ async function start(args, deps) {
|
|
|
288
370
|
if (!START_OPTIONS.includes(name))
|
|
289
371
|
throw new RegisterError(`--${name} is not an option of team start.\n${START_USAGE}`);
|
|
290
372
|
}
|
|
373
|
+
// Team build (Task 7): the folder you are in, its name, you as the lead. Nothing to pull.
|
|
374
|
+
if (opts.type !== undefined) {
|
|
375
|
+
if (opts.type !== 'build' || positional.length || Object.keys(opts).some((o) => !START_BUILD_OPTIONS.includes(o)))
|
|
376
|
+
throw new RegisterError(START_USAGE);
|
|
377
|
+
const dir = path.resolve(opts.folder && opts.folder !== 'true' ? opts.folder : process.cwd());
|
|
378
|
+
return initBuild(dir, opts.name && opts.name !== 'true' ? opts.name : path.basename(dir), deps.identity().name, deps);
|
|
379
|
+
}
|
|
291
380
|
const typed = positional.join(',').split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
|
|
292
381
|
if (typed.some(isPatternWord))
|
|
293
382
|
throw new RegisterError(NOT_A_PATTERN);
|
|
@@ -432,6 +521,12 @@ async function init(args, deps) {
|
|
|
432
521
|
const type = (opts.type ?? 'clean-core');
|
|
433
522
|
if (!TYPES.includes(type))
|
|
434
523
|
throw new RegisterError(`--type must be one of: ${TYPES.join(', ')}`);
|
|
524
|
+
if (type === 'build') {
|
|
525
|
+
// A team build pulls, measures and discovers nothing: those options are refused, as on `start --type build`.
|
|
526
|
+
if (Object.keys(opts).some((o) => !INIT_BUILD_OPTIONS.includes(o)))
|
|
527
|
+
throw new RegisterError(INIT_USAGE);
|
|
528
|
+
return initBuild(dir, name, lead, deps, given || undefined);
|
|
529
|
+
}
|
|
435
530
|
// One variant per team project (D6), named here or never. A name SAP could
|
|
436
531
|
// not accept is refused before a folder exists, not after it is written in.
|
|
437
532
|
const atcVariant = opts['atc-variant'] && opts['atc-variant'] !== 'true' ? opts['atc-variant'].trim().toUpperCase() : '';
|
|
@@ -691,6 +786,13 @@ function join(args, deps, asName) {
|
|
|
691
786
|
const state = loadState();
|
|
692
787
|
if (opts.as !== undefined && asName === undefined)
|
|
693
788
|
asName = joinAsName(opts.as, projectDir, project, state.projects[project.projectId]?.name, deps);
|
|
789
|
+
// F4/F8 — a team build is named by the signed-in account, once (a name
|
|
790
|
+
// already kept for this project stays).
|
|
791
|
+
if (asName === undefined && isBuildProject(project) && state.projects[project.projectId]?.name === undefined) {
|
|
792
|
+
const account = deps.account?.()?.trim();
|
|
793
|
+
if (account)
|
|
794
|
+
asName = account;
|
|
795
|
+
}
|
|
694
796
|
const outboxDir = path.resolve(opts.outbox ?? state.projects[project.projectId]?.outboxDir ?? defaultOutboxDir(project.projectId));
|
|
695
797
|
// A name given once is kept: joining again must not turn the lead back into
|
|
696
798
|
// whatever the machine calls them.
|
|
@@ -700,6 +802,11 @@ function join(args, deps, asName) {
|
|
|
700
802
|
state.projects[project.projectId] = { projectDir, outboxDir, writerId, ...(saved ? { name: saved } : {}) };
|
|
701
803
|
state.active = project.projectId;
|
|
702
804
|
saveState(state);
|
|
805
|
+
// A team build has no objects to own: one line.
|
|
806
|
+
if (isBuildProject(project)) {
|
|
807
|
+
deps.print(buildJoinLine(project, writerId));
|
|
808
|
+
return 0;
|
|
809
|
+
}
|
|
703
810
|
deps.print(`✓ Joined "${project.name}" (${objects} objects, lead ${project.lead.name}) as ${writerId}.`);
|
|
704
811
|
// Outbox is the team project folder itself: records land where everyone reads them.
|
|
705
812
|
if (path.relative(projectDir, outboxDir) === '') {
|
|
@@ -2494,7 +2601,7 @@ const heldExportLine = (name) => ` ! "${name}" is open in another program, so i
|
|
|
2494
2601
|
/** The CSV export of the team project folder, as the team page reads it, into `dir`. */
|
|
2495
2602
|
export function writeExports(projectDir, project, dir, deps) {
|
|
2496
2603
|
const { register } = registerFor(projectDir, outboxFor(projectDir));
|
|
2497
|
-
return writeExportFiles(register, project, dir, (deps.now?.() ?? new Date()).toISOString());
|
|
2604
|
+
return writeExportFiles(register, coreProject(project), dir, (deps.now?.() ?? new Date()).toISOString());
|
|
2498
2605
|
}
|
|
2499
2606
|
/**
|
|
2500
2607
|
* `team export [--out <folder>] [--always]` (spec §3.4, D2): the CSV files for
|
package/dist/journey/chain.js
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { estateSummaryOf, viewWords } from '@cspeach/register-core';
|
|
17
17
|
import { DEFAULT_ATC_VARIANT, FIRST_CHECK_PER_PACKAGE_LINE, FIRST_CHECK_WHOLE_MAX, isLead, ruleObject, runTeamCommand, whoAmI } from '../commands/team.js';
|
|
18
|
+
import { FUNCTIONAL_VERB_LINE } from '../commands/team-build.js';
|
|
19
|
+
import { isFunctionalSeat } from '../licence/seat-state.js';
|
|
18
20
|
import { activeProject, readBaseline, readEstate, readProjectFile, VARIANT_NAME } from '../register/store.js';
|
|
19
21
|
import { findingsByObject } from '../register/findings.js';
|
|
20
22
|
import { shown, shownUpTo } from '../register/atc-file.js';
|
|
@@ -245,6 +247,12 @@ export async function enterJourney(trigger, h) {
|
|
|
245
247
|
h.emit(trigger.line);
|
|
246
248
|
return;
|
|
247
249
|
}
|
|
250
|
+
// Final review F1: the clean-core and upgrade steps record work and run
|
|
251
|
+
// SAP's checks. A functional seat is told so once, before any card or read.
|
|
252
|
+
if (isFunctionalSeat()) {
|
|
253
|
+
h.emit(FUNCTIONAL_VERB_LINE);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
248
256
|
startJourneyFlow(h.session, trigger.kind === 'start' ? trigger.packages : []);
|
|
249
257
|
// B8: a start sentence is a new run: its DONE never reads as "nothing left" against an earlier one.
|
|
250
258
|
if (trigger.kind === 'start')
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import fs from 'node:fs';
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { projectView, provenForTarget as provenForTargetCore } from '@cspeach/register-core';
|
|
29
|
-
import { DISCOVERY_DIR, TEAM_FILE, activeProject, readBaseline, readEstate, readProjectFile } from '../register/store.js';
|
|
29
|
+
import { DISCOVERY_DIR, TEAM_FILE, activeProject, coreProject, readBaseline, readEstate, readProjectFile } from '../register/store.js';
|
|
30
30
|
import { registerFor } from '../register/picture.js';
|
|
31
31
|
import { reportFileNames } from '../register/workbooks.js';
|
|
32
32
|
import { samePath } from '../commands/team-discover.js';
|
|
@@ -250,7 +250,7 @@ export function readJourneyFacts(cwd, me, skip) {
|
|
|
250
250
|
const joined = activeProject();
|
|
251
251
|
// The project file folderRead already read (joined means its folder is this one).
|
|
252
252
|
facts.register = registerFor(joined.projectDir, joined.outboxDir, project).register;
|
|
253
|
-
facts.view = projectView(facts.register, project, skip?.decide ? { skipped: skip.decide } : {});
|
|
253
|
+
facts.view = projectView(facts.register, coreProject(project), skip?.decide ? { skipped: skip.decide } : {});
|
|
254
254
|
facts.reportsAt = reportsAt(cwd, folder.name);
|
|
255
255
|
}
|
|
256
256
|
catch {
|
package/dist/journey/rebuild.js
CHANGED
|
@@ -36,6 +36,8 @@ import { shown } from '../register/atc-file.js';
|
|
|
36
36
|
import { cleanReason, OBJECT_NAME, OBJECT_TYPE, objectShown } from './run.js';
|
|
37
37
|
import { clearJourneyFlow } from './flow.js';
|
|
38
38
|
import { whoAmI } from '../commands/team.js';
|
|
39
|
+
import { FUNCTIONAL_VERB_LINE } from '../commands/team-build.js';
|
|
40
|
+
import { isFunctionalSeat } from '../licence/seat-state.js';
|
|
39
41
|
import { setRebuildRunActive } from '../commands/plan-gate.js';
|
|
40
42
|
/* ── words ──────────────────────────────────────────────────────────────── */
|
|
41
43
|
export const KIND_WORDS = {
|
|
@@ -139,6 +141,9 @@ async function seatName() {
|
|
|
139
141
|
}
|
|
140
142
|
/** One evidence record: the `rebuild` row on each OLD object's key (additive; older readers drop it). */
|
|
141
143
|
export function writeRebuildRows(joined, keys, row, by, now = new Date()) {
|
|
144
|
+
// Final review F1: a functional seat records nothing into the project (every caller catches this).
|
|
145
|
+
if (isFunctionalSeat())
|
|
146
|
+
throw new Error(FUNCTIONAL_VERB_LINE);
|
|
142
147
|
const at = now.toISOString();
|
|
143
148
|
return writeRecord(joined, 'evidence', keys.map((key) => ({ key, rebuild: { ...(typeof row === 'function' ? row(key) : row), by, at } })), { name: by }, { now });
|
|
144
149
|
}
|
|
@@ -262,6 +267,9 @@ export function handOffRebuild(h, g, kind) {
|
|
|
262
267
|
}
|
|
263
268
|
const now = (h.now ?? (() => new Date()))();
|
|
264
269
|
try {
|
|
270
|
+
// Final review F1: no brief into the shared folder either, on a functional seat.
|
|
271
|
+
if (isFunctionalSeat())
|
|
272
|
+
throw new Error(FUNCTIONAL_VERB_LINE);
|
|
265
273
|
if (kind !== 'class') {
|
|
266
274
|
writeRebuildBrief(joined.projectDir, g.app, rebuildBrief({
|
|
267
275
|
kind, ...(h.sid ? { sid: h.sid } : {}), target: first.target ?? null,
|
package/dist/journey/run.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { estateSummaryOf, PART_ORDER, PART_WORDS, provenForTargetWords, usageLineWithWindow } from '@cspeach/register-core';
|
|
16
16
|
import { DEFAULT_ATC_VARIANT, FIRST_CHECK_PER_PACKAGE_LINE, FIRST_CHECK_WHOLE_MAX, NO_CHECK_ALL_LINE, ownerMatch, runTeamCommand, severalLine, whoAmI, writeExports, writeReports } from '../commands/team.js';
|
|
17
|
+
import { FUNCTIONAL_VERB_LINE } from '../commands/team-build.js';
|
|
18
|
+
import { isFunctionalSeat } from '../licence/seat-state.js';
|
|
17
19
|
import { EXPORTS_DIR, exportsOff, exportsOn, exportsPresent, setExportsAlways } from '../register/export-files.js';
|
|
18
20
|
import { shown, shownUpTo } from '../register/atc-file.js';
|
|
19
21
|
import { withoutRuleId } from '../register/records.js';
|
|
@@ -162,6 +164,11 @@ const IS_PATTERN = /[*?%]/;
|
|
|
162
164
|
*/
|
|
163
165
|
export async function runJourneyStage(stage, ctx) {
|
|
164
166
|
try {
|
|
167
|
+
// Final review F1: a functional seat reads a team project and never records
|
|
168
|
+
// into it — no start, no decision, no fix, no check, no report. Said before
|
|
169
|
+
// any card and before SAP is reached, headless too.
|
|
170
|
+
if (FUNCTIONAL_STAGES.has(stage) && isFunctionalSeat())
|
|
171
|
+
return result(stage, [FUNCTIONAL_VERB_LINE], { kind: 'stop' });
|
|
165
172
|
if (ctx.headless)
|
|
166
173
|
return nextStepOnly(stage, ctx);
|
|
167
174
|
// Said before any card: a step that needs SAP, in a session without one.
|
|
@@ -189,6 +196,8 @@ export async function runJourneyStage(stage, ctx) {
|
|
|
189
196
|
}
|
|
190
197
|
const me = (ctx) => whoAmI(ctx.team.deps);
|
|
191
198
|
const facts = (ctx) => readJourneyFacts(ctx.cwd, me(ctx), ctx.skips);
|
|
199
|
+
/** The stages that record work into the project or run SAP's checks: refused on a functional seat. */
|
|
200
|
+
const FUNCTIONAL_STAGES = new Set(['START', 'DECIDE', 'FIX', 'PROVE', 'REPORT']);
|
|
192
201
|
/** Run a team body with its output kept back; `said` holds only this run's lines. */
|
|
193
202
|
async function quiet(ctx, args) {
|
|
194
203
|
const { said } = ctx.team;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export async function readySpecFor(cwd, request) {
|
|
2
|
+
try {
|
|
3
|
+
const { buildFolderAt } = await import('../commands/team-spec.js');
|
|
4
|
+
const here = buildFolderAt(cwd);
|
|
5
|
+
if (!here)
|
|
6
|
+
return undefined;
|
|
7
|
+
const { listSharedSpecs } = await import('../projects/spec-share.js');
|
|
8
|
+
const all = listSharedSpecs(here.dir);
|
|
9
|
+
const said = request.toLowerCase();
|
|
10
|
+
const isNamed = (s) => said.includes(s.title.toLowerCase()) || said.includes(s.slug.replace(/-/g, ' '));
|
|
11
|
+
const namedAll = all.filter(isNamed);
|
|
12
|
+
const ready = all.filter((s) => s.status === 'ready for build');
|
|
13
|
+
const named = ready.filter(isNamed);
|
|
14
|
+
// S1 — the request names a spec, and it is not one that is ready.
|
|
15
|
+
if (named.length === 0 && namedAll.length === 1) {
|
|
16
|
+
return { notReady: { title: namedAll[0].title, status: namedAll[0].status } };
|
|
17
|
+
}
|
|
18
|
+
if (!ready.length)
|
|
19
|
+
return undefined;
|
|
20
|
+
const one = named.length === 1 ? named[0] : ready.length === 1 && namedAll.length === 0 ? ready[0] : undefined;
|
|
21
|
+
if (!one)
|
|
22
|
+
return { ask: ready.map((s) => `"${s.title}"`) };
|
|
23
|
+
const { chainFromToken } = await import('./chain-token.js');
|
|
24
|
+
const t = await chainFromToken(here.dir, one.envelopePath);
|
|
25
|
+
return t ? { command: `/abap-app --from ${t.token}`, title: one.title } : undefined;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** One plain line for a named spec that is not ready to build. */
|
|
32
|
+
export const notReadyLine = (n) => `"${n.title}" is ${n.status}, not ready for build. The author marks it ready when it is.`;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Final review S2 (2026-10-29): the signed-in CSPeach account, remembered on
|
|
3
|
+
* this laptop after a successful start-up (/v1/me), so a team build names a
|
|
4
|
+
* person by their account on every path — the typed `cspeach team join` and
|
|
5
|
+
* records written without a join — and two people on one laptop with one
|
|
6
|
+
* Windows user stay two people. Only the email; never sent anywhere. Kept in
|
|
7
|
+
* its own file so auth.json (which drives the key logic) is never touched.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
12
|
+
const file = () => path.join(cspeachRoot(), 'account.json');
|
|
13
|
+
export function rememberAccount(email) {
|
|
14
|
+
const e = email.trim();
|
|
15
|
+
if (!e)
|
|
16
|
+
return;
|
|
17
|
+
try {
|
|
18
|
+
fs.mkdirSync(cspeachRoot(), { recursive: true });
|
|
19
|
+
fs.writeFileSync(file(), JSON.stringify({ email: e }) + '\n', 'utf8');
|
|
20
|
+
}
|
|
21
|
+
catch { /* best-effort: the Windows name is used instead */ }
|
|
22
|
+
}
|
|
23
|
+
export function signedInAccount() {
|
|
24
|
+
try {
|
|
25
|
+
const j = JSON.parse(fs.readFileSync(file(), 'utf8'));
|
|
26
|
+
return typeof j.email === 'string' && j.email.trim() ? j.email.trim() : undefined;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/dist/licence/paths.js
CHANGED
|
@@ -4,3 +4,8 @@ import { cspeachRoot } from '../config/paths.js';
|
|
|
4
4
|
export function licenceCachePath() {
|
|
5
5
|
return path.join(cspeachRoot(), 'licence.json');
|
|
6
6
|
}
|
|
7
|
+
/** <CSPeach home>/seat.json — the last verified seat kind (Team build). Holds
|
|
8
|
+
* no secret: the MAC is keyed by the API key, which is not in the file. */
|
|
9
|
+
export function seatRecordPath() {
|
|
10
|
+
return path.join(cspeachRoot(), 'seat.json');
|
|
11
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team build — the seat record and the resolution rule.
|
|
3
|
+
*
|
|
4
|
+
* A developer is never wrongly locked read-only, and a functional seat never
|
|
5
|
+
* turns writable:
|
|
6
|
+
* 1. a fresh /v1/me answer wins (no field = an older proxy = developer);
|
|
7
|
+
* 2. else the last verified record in seat.json (HMAC over
|
|
8
|
+
* `keyPrefix\nkind\nverifiedAt`, keyed by the full API key);
|
|
9
|
+
* 3. else a pre-release sign-in (auth.json without `signedInWith`) is a
|
|
10
|
+
* developer;
|
|
11
|
+
* 4. else "not signed in" — never a guess.
|
|
12
|
+
* A record that is present but fails verification is `invalid`, never a
|
|
13
|
+
* fall-through to the legacy rule.
|
|
14
|
+
*/
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import { promises as fs } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { formatFailure } from '../renderer/notice-shape.js';
|
|
19
|
+
import { keyPrefixOf } from './licence-check.js';
|
|
20
|
+
import { seatRecordPath } from './paths.js';
|
|
21
|
+
const isSeatKind = (v) => v === 'developer' || v === 'functional';
|
|
22
|
+
export function seatMac(apiKey, keyPrefix, kind, verifiedAt) {
|
|
23
|
+
return crypto.createHmac('sha256', apiKey).update(`${keyPrefix}\n${kind}\n${verifiedAt}`).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
export function makeSeatRecord(apiKey, kind, now) {
|
|
26
|
+
const keyPrefix = keyPrefixOf(apiKey);
|
|
27
|
+
const verifiedAt = now.toISOString();
|
|
28
|
+
return { keyPrefix, kind, verifiedAt, mac: seatMac(apiKey, keyPrefix, kind, verifiedAt) };
|
|
29
|
+
}
|
|
30
|
+
export function verifySeatRecord(raw, apiKey) {
|
|
31
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
32
|
+
return null;
|
|
33
|
+
const r = raw;
|
|
34
|
+
if (typeof r.keyPrefix !== 'string' || typeof r.verifiedAt !== 'string' || typeof r.mac !== 'string')
|
|
35
|
+
return null;
|
|
36
|
+
if (!isSeatKind(r.kind))
|
|
37
|
+
return null;
|
|
38
|
+
if (r.keyPrefix !== keyPrefixOf(apiKey))
|
|
39
|
+
return null;
|
|
40
|
+
const expected = Buffer.from(seatMac(apiKey, r.keyPrefix, r.kind, r.verifiedAt), 'utf-8');
|
|
41
|
+
const given = Buffer.from(r.mac, 'utf-8');
|
|
42
|
+
// Lengths first: timingSafeEqual throws on unequal lengths.
|
|
43
|
+
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected))
|
|
44
|
+
return null;
|
|
45
|
+
return { keyPrefix: r.keyPrefix, kind: r.kind, verifiedAt: r.verifiedAt, mac: r.mac };
|
|
46
|
+
}
|
|
47
|
+
/** Ruling R7b: the legacy rule's input. No auth.json (a pasted or env key, or
|
|
48
|
+
* a pre-login keytar seat) or an auth.json without `signedInWith` is a
|
|
49
|
+
* pre-release sign-in; only a sign-in made by this CLI or newer is not. */
|
|
50
|
+
export function isLegacySignIn(authFile) {
|
|
51
|
+
return !authFile || authFile.signedInWith === undefined;
|
|
52
|
+
}
|
|
53
|
+
export function resolveSeatKind(a) {
|
|
54
|
+
if (a.fresh?.ok) {
|
|
55
|
+
const word = a.fresh.profile.seat_kind;
|
|
56
|
+
const kind = word === undefined ? 'developer' : isSeatKind(word) ? word : null;
|
|
57
|
+
if (kind === null)
|
|
58
|
+
return { ok: false, reason: 'invalid' };
|
|
59
|
+
return { ok: true, kind, source: 'fresh', write: makeSeatRecord(a.apiKey, kind, a.now) };
|
|
60
|
+
}
|
|
61
|
+
if (a.stored !== undefined && a.stored !== null) {
|
|
62
|
+
const rec = verifySeatRecord(a.stored, a.apiKey);
|
|
63
|
+
return rec ? { ok: true, kind: rec.kind, source: 'stored', write: null } : { ok: false, reason: 'invalid' };
|
|
64
|
+
}
|
|
65
|
+
if (a.legacySignIn)
|
|
66
|
+
return { ok: true, kind: 'developer', source: 'legacy', write: null };
|
|
67
|
+
return { ok: false, reason: 'missing' };
|
|
68
|
+
}
|
|
69
|
+
/** seat.json as stored: null when there is no file; `{ corrupt: true }` when
|
|
70
|
+
* it cannot be read or parsed, so it resolves `invalid`, never `missing`. */
|
|
71
|
+
export async function readSeatRecord() {
|
|
72
|
+
let text;
|
|
73
|
+
try {
|
|
74
|
+
text = await fs.readFile(seatRecordPath(), 'utf-8');
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (err.code === 'ENOENT')
|
|
78
|
+
return null;
|
|
79
|
+
return { corrupt: true };
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(text);
|
|
83
|
+
return parsed === null ? { corrupt: true } : parsed;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { corrupt: true };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export async function writeSeatRecord(r) {
|
|
90
|
+
const p = seatRecordPath();
|
|
91
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
92
|
+
// Only the known fields; temp file + rename, as writeLicenceCache.
|
|
93
|
+
const body = { keyPrefix: r.keyPrefix, kind: r.kind, verifiedAt: r.verifiedAt, mac: r.mac };
|
|
94
|
+
const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
95
|
+
try {
|
|
96
|
+
await fs.writeFile(tmp, JSON.stringify(body, null, 2), 'utf-8');
|
|
97
|
+
await fs.rename(tmp, p);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function seatSignInMessage() {
|
|
104
|
+
return formatFailure({
|
|
105
|
+
what: 'We could not confirm your seat',
|
|
106
|
+
detail: ['Your SAP work, sessions and snapshots are untouched.'],
|
|
107
|
+
next: 'cspeach login',
|
|
108
|
+
}).trimEnd();
|
|
109
|
+
}
|
|
110
|
+
export const READ_ONLY_SEAT_LINE = 'Read-only seat: CSPeach reads your SAP system and never changes it. Specs, notes and Word documents still save to your folder.';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { isLegacySignIn, resolveSeatKind, verifySeatRecord } from './seat-kind.js';
|
|
2
|
+
export async function resolveTerminalSeat(d) {
|
|
3
|
+
try {
|
|
4
|
+
const apiKey = (await d.resolveApiKey().catch(() => ({ key: null }))).key ?? '';
|
|
5
|
+
const stored = await d.readSeatRecord().catch(() => ({ corrupt: true }));
|
|
6
|
+
const authFile = await d.readAuthFile().catch(() => null);
|
|
7
|
+
const verified = stored !== null && stored !== undefined && apiKey !== '' && verifySeatRecord(stored, apiKey) !== null;
|
|
8
|
+
let fresh = null;
|
|
9
|
+
if (apiKey && !verified) {
|
|
10
|
+
try {
|
|
11
|
+
fresh = await d.fetchMe({ proxyUrl: await d.proxyUrl(), apiKey });
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
fresh = null;
|
|
15
|
+
}
|
|
16
|
+
if (fresh && !fresh.ok && fresh.status === 401)
|
|
17
|
+
return 'unconfirmed';
|
|
18
|
+
}
|
|
19
|
+
const r = resolveSeatKind({ fresh, stored, apiKey, now: d.now?.() ?? new Date(), legacySignIn: isLegacySignIn(authFile) });
|
|
20
|
+
if (!r.ok)
|
|
21
|
+
return 'unconfirmed';
|
|
22
|
+
if (r.write)
|
|
23
|
+
await d.writeSeatRecord(r.write).catch(() => { });
|
|
24
|
+
return r.kind;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return 'unconfirmed';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** The real wiring for cli.ts: the stored key, seat.json, auth.json, the configured proxy. */
|
|
31
|
+
export async function resolveTerminalSeatNow() {
|
|
32
|
+
const [{ resolveApiKey }, { readAuthFile }, { fetchMe }, { loadConfig }, seat] = await Promise.all([
|
|
33
|
+
import('../auth/key-resolver.js'), import('../auth/auth-file.js'), import('../auth/me.js'), import('../config/loader.js'), import('./seat-kind.js'),
|
|
34
|
+
]);
|
|
35
|
+
return resolveTerminalSeat({
|
|
36
|
+
resolveApiKey: () => resolveApiKey(),
|
|
37
|
+
readSeatRecord: seat.readSeatRecord,
|
|
38
|
+
writeSeatRecord: seat.writeSeatRecord,
|
|
39
|
+
readAuthFile: () => readAuthFile(),
|
|
40
|
+
fetchMe: (a) => fetchMe(a),
|
|
41
|
+
proxyUrl: async () => (await loadConfig()).proxy_url,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -61,6 +61,10 @@ export class LicenceTurnGate {
|
|
|
61
61
|
this.lastCheckAtMs = -Infinity;
|
|
62
62
|
return { allow: false, message: d.message };
|
|
63
63
|
}
|
|
64
|
+
try {
|
|
65
|
+
await this.o.onSeat?.(d);
|
|
66
|
+
}
|
|
67
|
+
catch { /* a seat refresh failure never blocks a turn */ }
|
|
64
68
|
return d.verdict === 'unknown' ? { allow: true, line: d.line } : { allow: true };
|
|
65
69
|
}
|
|
66
70
|
}
|