cli-copilot-worker 0.1.1 → 0.1.3
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 +237 -19
- package/dist/src/cli.js +5 -5
- package/dist/src/cli.js.map +1 -1
- package/dist/src/core/fleet-mode.d.ts +15 -0
- package/dist/src/core/fleet-mode.js +26 -0
- package/dist/src/core/fleet-mode.js.map +1 -0
- package/dist/src/core/model-catalog.d.ts +16 -0
- package/dist/src/core/model-catalog.js +95 -0
- package/dist/src/core/model-catalog.js.map +1 -0
- package/dist/src/core/profile-manager.js +1 -1
- package/dist/src/daemon/service.d.ts +8 -0
- package/dist/src/daemon/service.js +117 -2
- package/dist/src/daemon/service.js.map +1 -1
- package/dist/src/output.d.ts +1 -0
- package/dist/src/output.js +9 -0
- package/dist/src/output.js.map +1 -1
- package/package.json +2 -1
- package/src/cli.ts +5 -4
- package/src/core/fleet-mode.ts +39 -0
- package/src/core/model-catalog.ts +136 -0
- package/src/core/profile-manager.ts +1 -1
- package/src/daemon/service.ts +159 -3
- package/src/output.ts +12 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export interface ModelCatalogInput {
|
|
2
|
+
id: string;
|
|
3
|
+
name?: string | undefined;
|
|
4
|
+
policyState?: 'enabled' | 'disabled' | 'unconfigured' | undefined;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ModelAliasMapping {
|
|
8
|
+
alias: string;
|
|
9
|
+
canonical: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ModelCatalog {
|
|
13
|
+
latestModelIds: string[];
|
|
14
|
+
aliasMappings: ModelAliasMapping[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MANUALLY_EXCLUDED_MODEL_IDS = new Set(['gpt-4.1', 'claude-opus-4.6-fast']);
|
|
18
|
+
|
|
19
|
+
interface ParsedModelId {
|
|
20
|
+
family: string;
|
|
21
|
+
version: number[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isSelectableModel(model: ModelCatalogInput): boolean {
|
|
25
|
+
return (
|
|
26
|
+
model.policyState !== 'disabled' &&
|
|
27
|
+
model.policyState !== 'unconfigured' &&
|
|
28
|
+
!MANUALLY_EXCLUDED_MODEL_IDS.has(model.id)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseModelId(id: string): ParsedModelId | undefined {
|
|
33
|
+
const match = /^(.*)-(\d+(?:\.\d+)?)(?:-(.+))?$/.exec(id);
|
|
34
|
+
if (!match) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const prefix = match[1];
|
|
39
|
+
const version = match[2];
|
|
40
|
+
const suffix = match[3];
|
|
41
|
+
if (!prefix || !version) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const major = version.split('.')[0];
|
|
46
|
+
const familyBase = prefix.includes('-') ? prefix : `${prefix}-${major}`;
|
|
47
|
+
return {
|
|
48
|
+
family: suffix ? `${familyBase}-${suffix}` : familyBase,
|
|
49
|
+
version: version.split('.').map((part) => Number.parseInt(part, 10)),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function compareVersions(left: number[], right: number[]): number {
|
|
54
|
+
const length = Math.max(left.length, right.length);
|
|
55
|
+
for (let index = 0; index < length; index += 1) {
|
|
56
|
+
const leftPart = left[index] ?? 0;
|
|
57
|
+
const rightPart = right[index] ?? 0;
|
|
58
|
+
if (leftPart !== rightPart) {
|
|
59
|
+
return leftPart - rightPart;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildModelCatalog(models: ModelCatalogInput[]): ModelCatalog {
|
|
67
|
+
const selectable = models.filter(isSelectableModel);
|
|
68
|
+
const latestByFamily = new Map<string, { id: string; version: number[] }>();
|
|
69
|
+
const passthrough = new Set<string>();
|
|
70
|
+
|
|
71
|
+
for (const model of selectable) {
|
|
72
|
+
const parsed = parseModelId(model.id);
|
|
73
|
+
if (!parsed) {
|
|
74
|
+
passthrough.add(model.id);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const existing = latestByFamily.get(parsed.family);
|
|
79
|
+
if (!existing || compareVersions(parsed.version, existing.version) > 0) {
|
|
80
|
+
latestByFamily.set(parsed.family, { id: model.id, version: parsed.version });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const aliasMappings = selectable
|
|
85
|
+
.map((model) => {
|
|
86
|
+
const parsed = parseModelId(model.id);
|
|
87
|
+
if (!parsed) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const canonical = latestByFamily.get(parsed.family)?.id;
|
|
92
|
+
if (!canonical || canonical === model.id) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
alias: model.id,
|
|
98
|
+
canonical,
|
|
99
|
+
};
|
|
100
|
+
})
|
|
101
|
+
.filter((mapping): mapping is ModelAliasMapping => mapping !== undefined)
|
|
102
|
+
.sort((left, right) => left.alias.localeCompare(right.alias));
|
|
103
|
+
|
|
104
|
+
const latestModelIds = [
|
|
105
|
+
...new Set([
|
|
106
|
+
...passthrough,
|
|
107
|
+
...[...latestByFamily.values()].map((entry) => entry.id),
|
|
108
|
+
]),
|
|
109
|
+
].sort((left, right) => left.localeCompare(right));
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
latestModelIds,
|
|
113
|
+
aliasMappings,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function resolveModelAlias(requestedModel: string, catalog: ModelCatalog): string | undefined {
|
|
118
|
+
if (catalog.latestModelIds.includes(requestedModel)) {
|
|
119
|
+
return requestedModel;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return catalog.aliasMappings.find((mapping) => mapping.alias === requestedModel)?.canonical;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function describeAllowedModels(catalog: ModelCatalog): string {
|
|
126
|
+
const allowed = catalog.latestModelIds.join(', ');
|
|
127
|
+
if (catalog.aliasMappings.length === 0) {
|
|
128
|
+
return `Allowed models: ${allowed}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const aliases = catalog.aliasMappings
|
|
132
|
+
.map((mapping) => `${mapping.alias} -> ${mapping.canonical}`)
|
|
133
|
+
.join(', ');
|
|
134
|
+
|
|
135
|
+
return `Allowed models: ${allowed}. Accepted legacy aliases: ${aliases}`;
|
|
136
|
+
}
|
|
@@ -103,7 +103,7 @@ export class ProfileManager {
|
|
|
103
103
|
static fromEnvironment(persistedProfiles?: PersistedProfileState[]): ProfileManager {
|
|
104
104
|
const raw = process.env.COPILOT_CONFIG_DIRS;
|
|
105
105
|
const dirs = raw
|
|
106
|
-
? raw.split('
|
|
106
|
+
? raw.split(':').map((entry) => entry.trim()).filter(Boolean)
|
|
107
107
|
: [defaultProfileDir()];
|
|
108
108
|
|
|
109
109
|
return new ProfileManager({ profileDirs: dirs, persistedProfiles });
|
package/src/daemon/service.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { unlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
|
|
4
|
-
import { approveAll, type CopilotClient, type CopilotSession } from '@github/copilot-sdk';
|
|
4
|
+
import { approveAll, type CopilotClient, type CopilotSession, type ModelInfo } from '@github/copilot-sdk';
|
|
5
5
|
|
|
6
6
|
import { buildAuthenticatedClient } from '../core/copilot.js';
|
|
7
7
|
import { classifyCopilotStartupFailure, classifySessionErrorEvent } from '../core/failure-classifier.js';
|
|
8
|
+
import { startFleetModeIfEnabled } from '../core/fleet-mode.js';
|
|
9
|
+
import {
|
|
10
|
+
buildModelCatalog,
|
|
11
|
+
describeAllowedModels,
|
|
12
|
+
resolveModelAlias,
|
|
13
|
+
type ModelCatalogInput,
|
|
14
|
+
} from '../core/model-catalog.js';
|
|
8
15
|
import { ensureStateRoot } from '../core/paths.js';
|
|
9
16
|
import { ProfileFaultPlanner } from '../core/profile-faults.js';
|
|
10
17
|
import { ProfileManager } from '../core/profile-manager.js';
|
|
@@ -97,6 +104,7 @@ export class CliCopilotWorkerService {
|
|
|
97
104
|
await this.store.load();
|
|
98
105
|
this.profileManager = ProfileManager.fromEnvironment(this.store.getProfiles());
|
|
99
106
|
this.faultPlanner = ProfileFaultPlanner.fromEnvironment();
|
|
107
|
+
this.reconcilePendingQuestions();
|
|
100
108
|
this.store.setProfiles(this.profileManager.toPersistedState());
|
|
101
109
|
await this.store.persist();
|
|
102
110
|
}
|
|
@@ -123,6 +131,7 @@ export class CliCopilotWorkerService {
|
|
|
123
131
|
|
|
124
132
|
async shutdown(): Promise<Record<string, unknown>> {
|
|
125
133
|
for (const execution of this.activeExecutions.values()) {
|
|
134
|
+
this.clearPendingQuestion(execution.conversationId, execution.jobId, 'failed');
|
|
126
135
|
await execution.session.abort().catch(() => {});
|
|
127
136
|
await execution.session.disconnect().catch(() => {});
|
|
128
137
|
await execution.client.stop().catch(async () => {
|
|
@@ -150,9 +159,10 @@ export class CliCopilotWorkerService {
|
|
|
150
159
|
}
|
|
151
160
|
|
|
152
161
|
async run(args: RunRequest, writer?: EventWriter): Promise<Record<string, unknown>> {
|
|
162
|
+
const resolvedModel = await this.resolveRequestedModel(args.cwd, args.model);
|
|
153
163
|
const conversation = this.store.createConversation({
|
|
154
164
|
cwd: args.cwd,
|
|
155
|
-
model:
|
|
165
|
+
model: resolvedModel,
|
|
156
166
|
});
|
|
157
167
|
const job = this.store.createJob({
|
|
158
168
|
conversationId: conversation.id,
|
|
@@ -386,6 +396,7 @@ export class CliCopilotWorkerService {
|
|
|
386
396
|
failureMessage: 'Job cancelled.',
|
|
387
397
|
});
|
|
388
398
|
}
|
|
399
|
+
this.clearPendingQuestion(job.conversationId, job.id, 'cancelled');
|
|
389
400
|
await execution.session.abort().catch(() => {});
|
|
390
401
|
await execution.session.disconnect().catch(() => {});
|
|
391
402
|
await execution.client.stop().catch(async () => {
|
|
@@ -397,6 +408,7 @@ export class CliCopilotWorkerService {
|
|
|
397
408
|
this.store.finalizeJob(job.id, 'cancelled');
|
|
398
409
|
this.store.updateConversation(job.conversationId, {
|
|
399
410
|
status: 'cancelled',
|
|
411
|
+
pendingQuestion: undefined,
|
|
400
412
|
});
|
|
401
413
|
await this.store.appendTranscript({
|
|
402
414
|
conversationId: job.conversationId,
|
|
@@ -488,7 +500,16 @@ export class CliCopilotWorkerService {
|
|
|
488
500
|
message: `Running ${job.kind} on ${conversation.id}`,
|
|
489
501
|
});
|
|
490
502
|
|
|
491
|
-
|
|
503
|
+
let profiles: CopilotProfile[];
|
|
504
|
+
try {
|
|
505
|
+
profiles = await this.planExecutionProfiles(conversation);
|
|
506
|
+
} catch (error) {
|
|
507
|
+
const failure = classifyCopilotStartupFailure(error);
|
|
508
|
+
await this.finalizeJobFailure(job.id, conversation.id, failure.message);
|
|
509
|
+
rejectDone(new Error(failure.message));
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
492
513
|
if (profiles.length === 0) {
|
|
493
514
|
const failure = classifyCopilotStartupFailure(
|
|
494
515
|
new Error('No eligible Copilot profiles are available. All configured profiles are cooling down or unavailable.'),
|
|
@@ -670,6 +691,7 @@ export class CliCopilotWorkerService {
|
|
|
670
691
|
clearTimeout(timeout);
|
|
671
692
|
}
|
|
672
693
|
input.writer?.event('error', { message: failure.message });
|
|
694
|
+
this.clearPendingQuestion(input.conversation.id, input.job.id, 'failed');
|
|
673
695
|
await this.recordAttemptFailure({
|
|
674
696
|
conversationId: input.conversation.id,
|
|
675
697
|
jobId: input.job.id,
|
|
@@ -707,6 +729,7 @@ export class CliCopilotWorkerService {
|
|
|
707
729
|
lastError: undefined,
|
|
708
730
|
profileId: input.profile.id,
|
|
709
731
|
profileConfigDir: input.profile.configDir,
|
|
732
|
+
pendingQuestion: undefined,
|
|
710
733
|
});
|
|
711
734
|
await this.store.appendTranscript({
|
|
712
735
|
conversationId: input.conversation.id,
|
|
@@ -798,6 +821,16 @@ export class CliCopilotWorkerService {
|
|
|
798
821
|
|
|
799
822
|
void this.persistState()
|
|
800
823
|
.then(async () => {
|
|
824
|
+
const fleetStarted = await startFleetModeIfEnabled(session, input.content);
|
|
825
|
+
if (fleetStarted) {
|
|
826
|
+
await this.store.appendTranscript({
|
|
827
|
+
conversationId: input.conversation.id,
|
|
828
|
+
jobId: input.job.id,
|
|
829
|
+
role: 'status',
|
|
830
|
+
content: 'Fleet mode started.',
|
|
831
|
+
});
|
|
832
|
+
input.writer?.event('status', { message: 'Fleet mode started.' });
|
|
833
|
+
}
|
|
801
834
|
await session.send({ prompt: input.content });
|
|
802
835
|
})
|
|
803
836
|
.catch((error) => {
|
|
@@ -846,6 +879,28 @@ export class CliCopilotWorkerService {
|
|
|
846
879
|
return prioritized;
|
|
847
880
|
}
|
|
848
881
|
|
|
882
|
+
private async planExecutionProfiles(conversation: ConversationRecord): Promise<CopilotProfile[]> {
|
|
883
|
+
const prioritized = this.planProfiles(conversation);
|
|
884
|
+
if (conversation.hasStarted) {
|
|
885
|
+
return prioritized;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const supported = await this.filterProfilesByModelSupport(
|
|
889
|
+
conversation.cwd,
|
|
890
|
+
prioritized,
|
|
891
|
+
conversation.model,
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
if (supported.length > 0) {
|
|
895
|
+
return supported;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const catalog = await this.loadModelCatalogForProfiles(conversation.cwd, prioritized);
|
|
899
|
+
throw new Error(
|
|
900
|
+
`Unsupported model "${conversation.model}" for the configured Copilot profiles. ${describeAllowedModels(catalog)}`,
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
849
904
|
private async getOrCreateClient(cwd: string, profile: CopilotProfile): Promise<{ client: CopilotClient; clientKey: string }> {
|
|
850
905
|
const clientKey = `${cwd}:${profile.configDir}`;
|
|
851
906
|
let client = this.clients.get(clientKey);
|
|
@@ -867,6 +922,75 @@ export class CliCopilotWorkerService {
|
|
|
867
922
|
this.clients.delete(clientKey);
|
|
868
923
|
}
|
|
869
924
|
|
|
925
|
+
private async resolveRequestedModel(cwd: string, requestedModel?: string): Promise<string> {
|
|
926
|
+
const profiles = this.profileManager.getCandidateProfiles();
|
|
927
|
+
if (profiles.length === 0) {
|
|
928
|
+
throw new Error('No eligible Copilot profiles are available. All configured profiles are cooling down or unavailable.');
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const catalog = await this.loadModelCatalogForProfiles(cwd, profiles);
|
|
932
|
+
const rawModel = requestedModel ?? DEFAULT_MODEL;
|
|
933
|
+
const resolved = resolveModelAlias(rawModel, catalog);
|
|
934
|
+
if (!resolved) {
|
|
935
|
+
throw new Error(`Unsupported model "${rawModel}". ${describeAllowedModels(catalog)}`);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return resolved;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private async filterProfilesByModelSupport(
|
|
942
|
+
cwd: string,
|
|
943
|
+
profiles: CopilotProfile[],
|
|
944
|
+
model: string,
|
|
945
|
+
): Promise<CopilotProfile[]> {
|
|
946
|
+
const supportChecks = await Promise.all(
|
|
947
|
+
profiles.map(async (profile) => ({
|
|
948
|
+
profile,
|
|
949
|
+
supports: await this.profileSupportsModel(cwd, profile, model),
|
|
950
|
+
})),
|
|
951
|
+
);
|
|
952
|
+
|
|
953
|
+
return supportChecks
|
|
954
|
+
.filter((entry) => entry.supports)
|
|
955
|
+
.map((entry) => entry.profile);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
private async profileSupportsModel(cwd: string, profile: CopilotProfile, model: string): Promise<boolean> {
|
|
959
|
+
try {
|
|
960
|
+
const catalog = await this.loadModelCatalogForProfiles(cwd, [profile]);
|
|
961
|
+
return catalog.latestModelIds.includes(model);
|
|
962
|
+
} catch {
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
private async loadModelCatalogForProfiles(cwd: string, profiles: CopilotProfile[]) {
|
|
968
|
+
const settled = await Promise.allSettled(
|
|
969
|
+
profiles.map(async (profile) => {
|
|
970
|
+
const { client } = await this.getOrCreateClient(cwd, profile);
|
|
971
|
+
return await client.listModels();
|
|
972
|
+
}),
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
const models = settled
|
|
976
|
+
.flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
|
|
977
|
+
.map((model) => this.toModelCatalogInput(model));
|
|
978
|
+
|
|
979
|
+
if (models.length === 0) {
|
|
980
|
+
throw new Error('No selectable Copilot models are available from the configured profiles.');
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return buildModelCatalog(models);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
private toModelCatalogInput(model: ModelInfo): ModelCatalogInput {
|
|
987
|
+
return {
|
|
988
|
+
id: model.id,
|
|
989
|
+
name: model.name,
|
|
990
|
+
policyState: model.policy?.state,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
870
994
|
private async recordAttemptFailure(input: {
|
|
871
995
|
conversationId: string;
|
|
872
996
|
jobId: string;
|
|
@@ -900,6 +1024,7 @@ export class CliCopilotWorkerService {
|
|
|
900
1024
|
this.store.updateConversation(conversationId, {
|
|
901
1025
|
status: 'failed',
|
|
902
1026
|
lastError: message,
|
|
1027
|
+
pendingQuestion: undefined,
|
|
903
1028
|
});
|
|
904
1029
|
await this.store.appendTranscript({
|
|
905
1030
|
conversationId,
|
|
@@ -947,6 +1072,37 @@ export class CliCopilotWorkerService {
|
|
|
947
1072
|
await this.store.persist();
|
|
948
1073
|
}
|
|
949
1074
|
|
|
1075
|
+
private clearPendingQuestion(
|
|
1076
|
+
conversationId: string,
|
|
1077
|
+
jobId: string,
|
|
1078
|
+
status: ConversationRecord['status'],
|
|
1079
|
+
): void {
|
|
1080
|
+
this.questionRegistry.clear(conversationId, 'Conversation is no longer waiting for an answer.');
|
|
1081
|
+
this.store.updateConversation(conversationId, {
|
|
1082
|
+
pendingQuestion: undefined,
|
|
1083
|
+
status,
|
|
1084
|
+
});
|
|
1085
|
+
this.store.updateJob(jobId, {
|
|
1086
|
+
status: status === 'cancelled' ? 'cancelled' : 'failed',
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
private reconcilePendingQuestions(): void {
|
|
1091
|
+
for (const conversation of this.store.listConversations()) {
|
|
1092
|
+
if (!conversation.pendingQuestion) {
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (conversation.status === 'waiting_answer') {
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
this.store.updateConversation(conversation.id, {
|
|
1101
|
+
pendingQuestion: undefined,
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
950
1106
|
private resolveProfile(conversation: ConversationRecord) {
|
|
951
1107
|
if (conversation.profileConfigDir && conversation.profileId) {
|
|
952
1108
|
const match = this.profileManager
|
package/src/output.ts
CHANGED
|
@@ -22,6 +22,18 @@ export function resolveOutputFormat(explicit?: string | undefined): OutputFormat
|
|
|
22
22
|
throw new Error(`Invalid output format: ${explicit}. Use "text" or "json".`);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export function resolveFollowOutputFormat(explicit?: string | undefined): OutputFormat {
|
|
26
|
+
if (explicit === 'json') {
|
|
27
|
+
throw new Error('--follow only supports text output');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!explicit) {
|
|
31
|
+
return 'text';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return resolveOutputFormat(explicit);
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
export function printJson(data: unknown): void {
|
|
26
38
|
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
27
39
|
}
|