@ship-cli/core 0.0.1
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/.tsbuildinfo/src.tsbuildinfo +1 -0
- package/.tsbuildinfo/test.tsbuildinfo +1 -0
- package/LICENSE +21 -0
- package/dist/bin.js +49230 -0
- package/package.json +50 -0
- package/src/adapters/driven/auth/AuthServiceLive.ts +125 -0
- package/src/adapters/driven/config/ConfigRepositoryLive.ts +366 -0
- package/src/adapters/driven/linear/IssueRepositoryLive.ts +494 -0
- package/src/adapters/driven/linear/LinearClient.ts +33 -0
- package/src/adapters/driven/linear/Mapper.ts +142 -0
- package/src/adapters/driven/linear/ProjectRepositoryLive.ts +98 -0
- package/src/adapters/driven/linear/TeamRepositoryLive.ts +100 -0
- package/src/adapters/driving/cli/commands/block.ts +63 -0
- package/src/adapters/driving/cli/commands/blocked.ts +61 -0
- package/src/adapters/driving/cli/commands/create.ts +83 -0
- package/src/adapters/driving/cli/commands/done.ts +82 -0
- package/src/adapters/driving/cli/commands/init.ts +194 -0
- package/src/adapters/driving/cli/commands/list.ts +87 -0
- package/src/adapters/driving/cli/commands/login.ts +46 -0
- package/src/adapters/driving/cli/commands/prime.ts +114 -0
- package/src/adapters/driving/cli/commands/project.ts +155 -0
- package/src/adapters/driving/cli/commands/ready.ts +73 -0
- package/src/adapters/driving/cli/commands/show.ts +94 -0
- package/src/adapters/driving/cli/commands/start.ts +99 -0
- package/src/adapters/driving/cli/commands/team.ts +134 -0
- package/src/adapters/driving/cli/commands/unblock.ts +63 -0
- package/src/adapters/driving/cli/main.ts +70 -0
- package/src/bin.ts +12 -0
- package/src/domain/Config.ts +42 -0
- package/src/domain/Errors.ts +89 -0
- package/src/domain/Task.ts +124 -0
- package/src/domain/index.ts +3 -0
- package/src/infrastructure/Layers.ts +45 -0
- package/src/ports/AuthService.ts +19 -0
- package/src/ports/ConfigRepository.ts +20 -0
- package/src/ports/IssueRepository.ts +69 -0
- package/src/ports/PrService.ts +52 -0
- package/src/ports/ProjectRepository.ts +19 -0
- package/src/ports/TeamRepository.ts +17 -0
- package/src/ports/VcsService.ts +87 -0
- package/src/ports/index.ts +7 -0
- package/test/Dummy.test.ts +7 -0
- package/tsconfig.base.json +45 -0
- package/tsconfig.json +7 -0
- package/tsconfig.src.json +11 -0
- package/tsconfig.test.json +10 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +12 -0
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ship-cli/core",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Linear + jj workflow CLI for AI agents",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/user/ship",
|
|
10
|
+
"directory": "packages/cli"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"ship": "./dist/bin.js"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@effect/cli": "^0.69.0",
|
|
20
|
+
"@effect/language-service": "latest",
|
|
21
|
+
"@effect/platform": "^0.90.3",
|
|
22
|
+
"@effect/platform-node": "^0.96.0",
|
|
23
|
+
"@effect/vitest": "^0.25.1",
|
|
24
|
+
"@linear/sdk": "^29.0.0",
|
|
25
|
+
"@types/node": "^22.5.2",
|
|
26
|
+
"effect": "^3.17.7",
|
|
27
|
+
"oxfmt": "^0.19.0",
|
|
28
|
+
"oxlint": "^1.0.0",
|
|
29
|
+
"rimraf": "^5.0.0",
|
|
30
|
+
"tsup": "^8.2.4",
|
|
31
|
+
"tsx": "^4.19.1",
|
|
32
|
+
"typescript": "^5.6.2",
|
|
33
|
+
"vitest": "^3.2.0",
|
|
34
|
+
"yaml": "^2.7.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@clack/prompts": "^0.11.0"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"dev": "tsx src/bin.ts",
|
|
41
|
+
"build": "tsup",
|
|
42
|
+
"clean": "rimraf dist/*",
|
|
43
|
+
"check": "tsc -b tsconfig.json",
|
|
44
|
+
"lint": "oxlint",
|
|
45
|
+
"format": "oxfmt --write src/",
|
|
46
|
+
"format:check": "oxfmt src/",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"coverage": "vitest run --coverage"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Layer from "effect/Layer";
|
|
3
|
+
import * as Option from "effect/Option";
|
|
4
|
+
import * as FetchHttpClient from "@effect/platform/FetchHttpClient";
|
|
5
|
+
import * as HttpClient from "@effect/platform/HttpClient";
|
|
6
|
+
import * as HttpClientRequest from "@effect/platform/HttpClientRequest";
|
|
7
|
+
import { AuthConfig } from "../../../domain/Config.js";
|
|
8
|
+
import { AuthError, ConfigError, NotAuthenticatedError } from "../../../domain/Errors.js";
|
|
9
|
+
import { AuthService } from "../../../ports/AuthService.js";
|
|
10
|
+
import { ConfigRepository } from "../../../ports/ConfigRepository.js";
|
|
11
|
+
|
|
12
|
+
const LINEAR_API_URL = "https://api.linear.app/graphql";
|
|
13
|
+
|
|
14
|
+
const make = Effect.gen(function* () {
|
|
15
|
+
const config = yield* ConfigRepository;
|
|
16
|
+
|
|
17
|
+
const validateApiKey = (apiKey: string): Effect.Effect<boolean, AuthError> =>
|
|
18
|
+
Effect.gen(function* () {
|
|
19
|
+
const client = yield* HttpClient.HttpClient;
|
|
20
|
+
|
|
21
|
+
// Simple query to validate the token
|
|
22
|
+
const query = `{ viewer { id name } }`;
|
|
23
|
+
|
|
24
|
+
const request = HttpClientRequest.post(LINEAR_API_URL).pipe(
|
|
25
|
+
HttpClientRequest.setHeader("Content-Type", "application/json"),
|
|
26
|
+
HttpClientRequest.setHeader("Authorization", apiKey),
|
|
27
|
+
HttpClientRequest.bodyJson({ query }),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const response = yield* request.pipe(
|
|
31
|
+
Effect.flatMap((req) => client.execute(req)),
|
|
32
|
+
Effect.flatMap((res) => res.json),
|
|
33
|
+
Effect.catchAll((e) =>
|
|
34
|
+
Effect.fail(new AuthError({ message: `Failed to validate API key: ${e}` })),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const data = response as {
|
|
39
|
+
data?: { viewer?: { id: string } };
|
|
40
|
+
errors?: Array<{ message: string }>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (data.errors && data.errors.length > 0) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return !!data.data?.viewer?.id;
|
|
48
|
+
}).pipe(Effect.provide(FetchHttpClient.layer));
|
|
49
|
+
|
|
50
|
+
const saveApiKey = (apiKey: string): Effect.Effect<AuthConfig, AuthError> =>
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
const isValid = yield* validateApiKey(apiKey);
|
|
53
|
+
|
|
54
|
+
if (!isValid) {
|
|
55
|
+
return yield* Effect.fail(
|
|
56
|
+
new AuthError({ message: "Invalid API key. Please check and try again." }),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const auth = new AuthConfig({ apiKey });
|
|
61
|
+
|
|
62
|
+
yield* config
|
|
63
|
+
.saveAuth(auth)
|
|
64
|
+
.pipe(
|
|
65
|
+
Effect.mapError(
|
|
66
|
+
(e) => new AuthError({ message: `Failed to save API key: ${e.message}` }),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Ensure .ship is in .gitignore (API key is sensitive)
|
|
71
|
+
yield* config
|
|
72
|
+
.ensureGitignore()
|
|
73
|
+
.pipe(
|
|
74
|
+
Effect.mapError(
|
|
75
|
+
(e) => new AuthError({ message: `Failed to update .gitignore: ${e.message}` }),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return auth;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const getApiKey = (): Effect.Effect<string, NotAuthenticatedError | ConfigError> =>
|
|
83
|
+
Effect.gen(function* () {
|
|
84
|
+
const cfg = yield* config.loadPartial();
|
|
85
|
+
|
|
86
|
+
if (Option.isNone(cfg.auth)) {
|
|
87
|
+
return yield* Effect.fail(
|
|
88
|
+
new NotAuthenticatedError({ message: "Not authenticated. Run 'ship login' first." }),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return cfg.auth.value.apiKey;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const logout = (): Effect.Effect<void, never> =>
|
|
96
|
+
config.delete().pipe(
|
|
97
|
+
Effect.tapError((e) =>
|
|
98
|
+
Effect.logWarning(`Failed to delete config during logout: ${e.message}`),
|
|
99
|
+
),
|
|
100
|
+
Effect.ignore,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const isAuthenticated = (): Effect.Effect<boolean, never> =>
|
|
104
|
+
Effect.gen(function* () {
|
|
105
|
+
const cfg = yield* config.loadPartial();
|
|
106
|
+
return Option.isSome(cfg.auth);
|
|
107
|
+
}).pipe(
|
|
108
|
+
// Config errors mean we can't determine auth status - log and return false
|
|
109
|
+
Effect.catchAll((e) =>
|
|
110
|
+
Effect.logWarning(`Error checking authentication: ${e.message}`).pipe(
|
|
111
|
+
Effect.map(() => false),
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
saveApiKey,
|
|
118
|
+
validateApiKey,
|
|
119
|
+
getApiKey,
|
|
120
|
+
logout,
|
|
121
|
+
isAuthenticated,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const AuthServiceLive = Layer.effect(AuthService, make);
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Layer from "effect/Layer";
|
|
3
|
+
import * as Option from "effect/Option";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
import * as FileSystem from "@effect/platform/FileSystem";
|
|
6
|
+
import * as Path from "@effect/platform/Path";
|
|
7
|
+
import * as YAML from "yaml";
|
|
8
|
+
import {
|
|
9
|
+
AuthConfig,
|
|
10
|
+
GitConfig,
|
|
11
|
+
LinearConfig,
|
|
12
|
+
PartialShipConfig,
|
|
13
|
+
PrConfig,
|
|
14
|
+
CommitConfig,
|
|
15
|
+
ShipConfig,
|
|
16
|
+
} from "../../../domain/Config.js";
|
|
17
|
+
import { ConfigError, WorkspaceNotInitializedError } from "../../../domain/Errors.js";
|
|
18
|
+
import { ConfigRepository } from "../../../ports/ConfigRepository.js";
|
|
19
|
+
import { TeamId, ProjectId } from "../../../domain/Task.js";
|
|
20
|
+
|
|
21
|
+
// Helper to convert config strings to branded types
|
|
22
|
+
// These are stored values that we trust as valid IDs
|
|
23
|
+
const asTeamId = (s: string): typeof TeamId.Type => s as typeof TeamId.Type;
|
|
24
|
+
const asProjectId = (s: string): typeof ProjectId.Type => s as typeof ProjectId.Type;
|
|
25
|
+
|
|
26
|
+
const CONFIG_DIR = ".ship";
|
|
27
|
+
const CONFIG_FILE = "config.yaml";
|
|
28
|
+
|
|
29
|
+
// YAML representation (with optional fields for partial configs)
|
|
30
|
+
const YamlConfig = Schema.Struct({
|
|
31
|
+
linear: Schema.optional(
|
|
32
|
+
Schema.Struct({
|
|
33
|
+
teamId: Schema.String,
|
|
34
|
+
teamKey: Schema.String,
|
|
35
|
+
projectId: Schema.NullOr(Schema.String),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
auth: Schema.optional(
|
|
39
|
+
Schema.Struct({
|
|
40
|
+
apiKey: Schema.String,
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
git: Schema.optional(
|
|
44
|
+
Schema.Struct({
|
|
45
|
+
defaultBranch: Schema.optional(Schema.String),
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
pr: Schema.optional(
|
|
49
|
+
Schema.Struct({
|
|
50
|
+
openBrowser: Schema.optional(Schema.Boolean),
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
commit: Schema.optional(
|
|
54
|
+
Schema.Struct({
|
|
55
|
+
conventionalFormat: Schema.optional(Schema.Boolean),
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
type YamlConfig = typeof YamlConfig.Type;
|
|
61
|
+
|
|
62
|
+
interface MutableYamlConfig {
|
|
63
|
+
linear?: {
|
|
64
|
+
teamId: string;
|
|
65
|
+
teamKey: string;
|
|
66
|
+
projectId: string | null;
|
|
67
|
+
};
|
|
68
|
+
auth?: {
|
|
69
|
+
apiKey: string;
|
|
70
|
+
};
|
|
71
|
+
git?: {
|
|
72
|
+
defaultBranch?: string;
|
|
73
|
+
};
|
|
74
|
+
pr?: {
|
|
75
|
+
openBrowser?: boolean;
|
|
76
|
+
};
|
|
77
|
+
commit?: {
|
|
78
|
+
conventionalFormat?: boolean;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const make = Effect.gen(function* () {
|
|
83
|
+
const fs = yield* FileSystem.FileSystem;
|
|
84
|
+
const path = yield* Path.Path;
|
|
85
|
+
|
|
86
|
+
const getConfigDir = () => Effect.succeed(path.join(process.cwd(), CONFIG_DIR));
|
|
87
|
+
|
|
88
|
+
const getConfigPath = () => Effect.map(getConfigDir(), (dir) => path.join(dir, CONFIG_FILE));
|
|
89
|
+
|
|
90
|
+
const ensureConfigDir = () =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const dir = yield* getConfigDir();
|
|
93
|
+
const dirExists = yield* fs.exists(dir);
|
|
94
|
+
if (!dirExists) {
|
|
95
|
+
yield* fs.makeDirectory(dir, { recursive: true });
|
|
96
|
+
}
|
|
97
|
+
}).pipe(
|
|
98
|
+
Effect.catchAll((e) =>
|
|
99
|
+
Effect.fail(new ConfigError({ message: `Failed to create config directory: ${e}` })),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const ensureGitignore = () =>
|
|
104
|
+
Effect.gen(function* () {
|
|
105
|
+
const gitignorePath = path.join(process.cwd(), ".gitignore");
|
|
106
|
+
const gitignoreExists = yield* fs.exists(gitignorePath);
|
|
107
|
+
|
|
108
|
+
if (gitignoreExists) {
|
|
109
|
+
const content = yield* fs.readFileString(gitignorePath);
|
|
110
|
+
// Check if .ship is already in gitignore
|
|
111
|
+
const lines = content.split("\n");
|
|
112
|
+
const hasShip = lines.some((line) => line.trim() === ".ship" || line.trim() === ".ship/");
|
|
113
|
+
if (!hasShip) {
|
|
114
|
+
// Append .ship to gitignore
|
|
115
|
+
const newContent = content.endsWith("\n") ? `${content}.ship/\n` : `${content}\n.ship/\n`;
|
|
116
|
+
yield* fs.writeFileString(gitignorePath, newContent);
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
// Create new .gitignore with .ship
|
|
120
|
+
yield* fs.writeFileString(gitignorePath, ".ship/\n");
|
|
121
|
+
}
|
|
122
|
+
}).pipe(
|
|
123
|
+
Effect.catchAll((e) =>
|
|
124
|
+
Effect.fail(new ConfigError({ message: `Failed to update .gitignore: ${e}` })),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const exists = () =>
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
const configPath = yield* getConfigPath();
|
|
131
|
+
return yield* fs.exists(configPath);
|
|
132
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(false)));
|
|
133
|
+
|
|
134
|
+
const readYaml = (): Effect.Effect<YamlConfig | null, ConfigError> =>
|
|
135
|
+
Effect.gen(function* () {
|
|
136
|
+
const configPath = yield* getConfigPath();
|
|
137
|
+
const fileExists = yield* fs.exists(configPath);
|
|
138
|
+
if (!fileExists) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const content = yield* fs.readFileString(configPath);
|
|
142
|
+
|
|
143
|
+
// Parse YAML
|
|
144
|
+
let parsed: unknown;
|
|
145
|
+
try {
|
|
146
|
+
parsed = YAML.parse(content);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return yield* Effect.fail(
|
|
149
|
+
new ConfigError({
|
|
150
|
+
message: `Invalid YAML in .ship/config.yaml: ${e instanceof Error ? e.message : e}`,
|
|
151
|
+
cause: e,
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Validate schema
|
|
157
|
+
return yield* Schema.decodeUnknown(YamlConfig)(parsed).pipe(
|
|
158
|
+
Effect.mapError(
|
|
159
|
+
(e) =>
|
|
160
|
+
new ConfigError({
|
|
161
|
+
message: `Invalid config in .ship/config.yaml. Run 'ship init' to reconfigure.\nDetails: ${e.message}`,
|
|
162
|
+
cause: e,
|
|
163
|
+
}),
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
}).pipe(
|
|
167
|
+
Effect.catchAll((e) => {
|
|
168
|
+
if (e instanceof ConfigError) {
|
|
169
|
+
return Effect.fail(e);
|
|
170
|
+
}
|
|
171
|
+
return Effect.fail(new ConfigError({ message: `Failed to read config: ${e}`, cause: e }));
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const writeYaml = (yamlConfig: MutableYamlConfig): Effect.Effect<void, ConfigError> =>
|
|
176
|
+
Effect.gen(function* () {
|
|
177
|
+
yield* ensureConfigDir();
|
|
178
|
+
const configPath = yield* getConfigPath();
|
|
179
|
+
const content = YAML.stringify(yamlConfig);
|
|
180
|
+
yield* fs.writeFileString(configPath, content);
|
|
181
|
+
}).pipe(
|
|
182
|
+
Effect.catchAll((e) =>
|
|
183
|
+
Effect.fail(new ConfigError({ message: `Failed to write config: ${e}`, cause: e })),
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const yamlToPartial = (yaml: YamlConfig | null): PartialShipConfig => {
|
|
188
|
+
if (!yaml) {
|
|
189
|
+
return new PartialShipConfig({
|
|
190
|
+
linear: Option.none(),
|
|
191
|
+
auth: Option.none(),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return new PartialShipConfig({
|
|
196
|
+
linear: yaml.linear
|
|
197
|
+
? Option.some(
|
|
198
|
+
new LinearConfig({
|
|
199
|
+
teamId: asTeamId(yaml.linear.teamId),
|
|
200
|
+
teamKey: yaml.linear.teamKey,
|
|
201
|
+
projectId: yaml.linear.projectId
|
|
202
|
+
? Option.some(asProjectId(yaml.linear.projectId))
|
|
203
|
+
: Option.none(),
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
: Option.none(),
|
|
207
|
+
auth: yaml.auth ? Option.some(new AuthConfig({ apiKey: yaml.auth.apiKey })) : Option.none(),
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const partialToYaml = (partialConfig: PartialShipConfig): MutableYamlConfig => {
|
|
212
|
+
const yaml: MutableYamlConfig = {};
|
|
213
|
+
|
|
214
|
+
if (Option.isSome(partialConfig.linear)) {
|
|
215
|
+
const linear = partialConfig.linear.value;
|
|
216
|
+
yaml.linear = {
|
|
217
|
+
teamId: linear.teamId,
|
|
218
|
+
teamKey: linear.teamKey,
|
|
219
|
+
projectId: Option.isSome(linear.projectId) ? linear.projectId.value : null,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (Option.isSome(partialConfig.auth)) {
|
|
224
|
+
yaml.auth = { apiKey: partialConfig.auth.value.apiKey };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (partialConfig.git) {
|
|
228
|
+
yaml.git = { defaultBranch: partialConfig.git.defaultBranch };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (partialConfig.pr) {
|
|
232
|
+
yaml.pr = { openBrowser: partialConfig.pr.openBrowser };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (partialConfig.commit) {
|
|
236
|
+
yaml.commit = { conventionalFormat: partialConfig.commit.conventionalFormat };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return yaml;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const fullToYaml = (fullConfig: ShipConfig): MutableYamlConfig => ({
|
|
243
|
+
linear: {
|
|
244
|
+
teamId: fullConfig.linear.teamId,
|
|
245
|
+
teamKey: fullConfig.linear.teamKey,
|
|
246
|
+
projectId: Option.isSome(fullConfig.linear.projectId)
|
|
247
|
+
? fullConfig.linear.projectId.value
|
|
248
|
+
: null,
|
|
249
|
+
},
|
|
250
|
+
auth: { apiKey: fullConfig.auth.apiKey },
|
|
251
|
+
git: { defaultBranch: fullConfig.git.defaultBranch },
|
|
252
|
+
pr: { openBrowser: fullConfig.pr.openBrowser },
|
|
253
|
+
commit: { conventionalFormat: fullConfig.commit.conventionalFormat },
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const load = (): Effect.Effect<ShipConfig, WorkspaceNotInitializedError | ConfigError> =>
|
|
257
|
+
Effect.gen(function* () {
|
|
258
|
+
const yaml = yield* readYaml();
|
|
259
|
+
if (!yaml || !yaml.linear || !yaml.auth) {
|
|
260
|
+
return yield* Effect.fail(WorkspaceNotInitializedError.default);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return new ShipConfig({
|
|
264
|
+
linear: new LinearConfig({
|
|
265
|
+
teamId: asTeamId(yaml.linear.teamId),
|
|
266
|
+
teamKey: yaml.linear.teamKey,
|
|
267
|
+
projectId: yaml.linear.projectId
|
|
268
|
+
? Option.some(asProjectId(yaml.linear.projectId))
|
|
269
|
+
: Option.none(),
|
|
270
|
+
}),
|
|
271
|
+
auth: new AuthConfig({ apiKey: yaml.auth.apiKey }),
|
|
272
|
+
git: new GitConfig({ defaultBranch: yaml.git?.defaultBranch ?? "main" }),
|
|
273
|
+
pr: new PrConfig({ openBrowser: yaml.pr?.openBrowser ?? true }),
|
|
274
|
+
commit: new CommitConfig({ conventionalFormat: yaml.commit?.conventionalFormat ?? true }),
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const loadPartial = () =>
|
|
279
|
+
Effect.gen(function* () {
|
|
280
|
+
const yaml = yield* readYaml();
|
|
281
|
+
return yamlToPartial(yaml);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const save = (fullConfig: ShipConfig) =>
|
|
285
|
+
Effect.gen(function* () {
|
|
286
|
+
const yaml = fullToYaml(fullConfig);
|
|
287
|
+
yield* writeYaml(yaml);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const savePartial = (partialConfig: PartialShipConfig) =>
|
|
291
|
+
Effect.gen(function* () {
|
|
292
|
+
const yaml = partialToYaml(partialConfig);
|
|
293
|
+
yield* writeYaml(yaml);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const saveAuth = (auth: AuthConfig) =>
|
|
297
|
+
Effect.gen(function* () {
|
|
298
|
+
const existingYaml = yield* readYaml();
|
|
299
|
+
const yaml: MutableYamlConfig = {};
|
|
300
|
+
if (existingYaml?.linear) {
|
|
301
|
+
yaml.linear = {
|
|
302
|
+
teamId: existingYaml.linear.teamId,
|
|
303
|
+
teamKey: existingYaml.linear.teamKey,
|
|
304
|
+
projectId: existingYaml.linear.projectId,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (existingYaml?.git?.defaultBranch)
|
|
308
|
+
yaml.git = { defaultBranch: existingYaml.git.defaultBranch };
|
|
309
|
+
if (existingYaml?.pr?.openBrowser !== undefined)
|
|
310
|
+
yaml.pr = { openBrowser: existingYaml.pr.openBrowser };
|
|
311
|
+
if (existingYaml?.commit?.conventionalFormat !== undefined)
|
|
312
|
+
yaml.commit = { conventionalFormat: existingYaml.commit.conventionalFormat };
|
|
313
|
+
yaml.auth = { apiKey: auth.apiKey };
|
|
314
|
+
yield* writeYaml(yaml);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const saveLinear = (linear: LinearConfig) =>
|
|
318
|
+
Effect.gen(function* () {
|
|
319
|
+
const existingYaml = yield* readYaml();
|
|
320
|
+
const yaml: MutableYamlConfig = {};
|
|
321
|
+
if (existingYaml?.auth) {
|
|
322
|
+
yaml.auth = { apiKey: existingYaml.auth.apiKey };
|
|
323
|
+
}
|
|
324
|
+
if (existingYaml?.git?.defaultBranch)
|
|
325
|
+
yaml.git = { defaultBranch: existingYaml.git.defaultBranch };
|
|
326
|
+
if (existingYaml?.pr?.openBrowser !== undefined)
|
|
327
|
+
yaml.pr = { openBrowser: existingYaml.pr.openBrowser };
|
|
328
|
+
if (existingYaml?.commit?.conventionalFormat !== undefined)
|
|
329
|
+
yaml.commit = { conventionalFormat: existingYaml.commit.conventionalFormat };
|
|
330
|
+
yaml.linear = {
|
|
331
|
+
teamId: linear.teamId,
|
|
332
|
+
teamKey: linear.teamKey,
|
|
333
|
+
projectId: Option.isSome(linear.projectId) ? linear.projectId.value : null,
|
|
334
|
+
};
|
|
335
|
+
yield* writeYaml(yaml);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const deleteConfig = () =>
|
|
339
|
+
Effect.gen(function* () {
|
|
340
|
+
const configPath = yield* getConfigPath();
|
|
341
|
+
const fileExists = yield* fs.exists(configPath);
|
|
342
|
+
if (fileExists) {
|
|
343
|
+
yield* fs.remove(configPath);
|
|
344
|
+
}
|
|
345
|
+
}).pipe(
|
|
346
|
+
Effect.catchAll((e) =>
|
|
347
|
+
Effect.fail(new ConfigError({ message: `Failed to delete config: ${e}`, cause: e })),
|
|
348
|
+
),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
load,
|
|
353
|
+
loadPartial,
|
|
354
|
+
save,
|
|
355
|
+
savePartial,
|
|
356
|
+
saveAuth,
|
|
357
|
+
saveLinear,
|
|
358
|
+
exists,
|
|
359
|
+
getConfigDir,
|
|
360
|
+
ensureConfigDir,
|
|
361
|
+
ensureGitignore,
|
|
362
|
+
delete: deleteConfig,
|
|
363
|
+
};
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
export const ConfigRepositoryLive = Layer.effect(ConfigRepository, make);
|