@ship-cli/core 0.0.3 → 0.1.0

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.
Files changed (52) hide show
  1. package/README.md +90 -0
  2. package/dist/bin.js +43263 -30230
  3. package/package.json +47 -23
  4. package/.tsbuildinfo/src.tsbuildinfo +0 -1
  5. package/.tsbuildinfo/test.tsbuildinfo +0 -1
  6. package/LICENSE +0 -21
  7. package/src/adapters/driven/auth/AuthServiceLive.ts +0 -125
  8. package/src/adapters/driven/config/ConfigRepositoryLive.ts +0 -366
  9. package/src/adapters/driven/linear/IssueRepositoryLive.ts +0 -528
  10. package/src/adapters/driven/linear/LinearClient.ts +0 -33
  11. package/src/adapters/driven/linear/Mapper.ts +0 -142
  12. package/src/adapters/driven/linear/ProjectRepositoryLive.ts +0 -98
  13. package/src/adapters/driven/linear/TeamRepositoryLive.ts +0 -101
  14. package/src/adapters/driving/cli/commands/block.ts +0 -63
  15. package/src/adapters/driving/cli/commands/blocked.ts +0 -61
  16. package/src/adapters/driving/cli/commands/create.ts +0 -83
  17. package/src/adapters/driving/cli/commands/done.ts +0 -82
  18. package/src/adapters/driving/cli/commands/init.ts +0 -194
  19. package/src/adapters/driving/cli/commands/list.ts +0 -87
  20. package/src/adapters/driving/cli/commands/login.ts +0 -46
  21. package/src/adapters/driving/cli/commands/prime.ts +0 -123
  22. package/src/adapters/driving/cli/commands/project.ts +0 -155
  23. package/src/adapters/driving/cli/commands/ready.ts +0 -73
  24. package/src/adapters/driving/cli/commands/relate.ts +0 -56
  25. package/src/adapters/driving/cli/commands/show.ts +0 -94
  26. package/src/adapters/driving/cli/commands/start.ts +0 -101
  27. package/src/adapters/driving/cli/commands/team.ts +0 -135
  28. package/src/adapters/driving/cli/commands/unblock.ts +0 -63
  29. package/src/adapters/driving/cli/commands/update.ts +0 -125
  30. package/src/adapters/driving/cli/main.ts +0 -76
  31. package/src/bin.ts +0 -12
  32. package/src/domain/Config.ts +0 -42
  33. package/src/domain/Errors.ts +0 -89
  34. package/src/domain/Task.ts +0 -124
  35. package/src/domain/index.ts +0 -3
  36. package/src/infrastructure/Layers.ts +0 -45
  37. package/src/ports/AuthService.ts +0 -19
  38. package/src/ports/ConfigRepository.ts +0 -20
  39. package/src/ports/IssueRepository.ts +0 -75
  40. package/src/ports/PrService.ts +0 -52
  41. package/src/ports/ProjectRepository.ts +0 -19
  42. package/src/ports/TeamRepository.ts +0 -17
  43. package/src/ports/VcsService.ts +0 -87
  44. package/src/ports/index.ts +0 -7
  45. package/test/Dummy.test.ts +0 -7
  46. package/tsconfig.base.json +0 -45
  47. package/tsconfig.json +0 -7
  48. package/tsconfig.src.json +0 -11
  49. package/tsconfig.test.json +0 -10
  50. package/tsconfig.tsbuildinfo +0 -1
  51. package/tsup.config.ts +0 -14
  52. package/vitest.config.ts +0 -12
@@ -1,98 +0,0 @@
1
- import * as Effect from "effect/Effect";
2
- import * as Layer from "effect/Layer";
3
- import * as Schedule from "effect/Schedule";
4
- import * as Duration from "effect/Duration";
5
- import { ProjectRepository, type CreateProjectInput } from "../../../ports/ProjectRepository.js";
6
- import { LinearClientService } from "./LinearClient.js";
7
- import { Project, type TeamId } from "../../../domain/Task.js";
8
- import { LinearApiError, TaskError } from "../../../domain/Errors.js";
9
- import { mapProject } from "./Mapper.js";
10
-
11
- // Retry policy: exponential backoff with max 3 retries
12
- const retryPolicy = Schedule.intersect(
13
- Schedule.exponential(Duration.millis(100)),
14
- Schedule.recurs(3),
15
- );
16
-
17
- // Timeout for API calls: 30 seconds
18
- const API_TIMEOUT = Duration.seconds(30);
19
-
20
- const withRetryAndTimeout = <A, E>(
21
- effect: Effect.Effect<A, E>,
22
- operation: string,
23
- ): Effect.Effect<A, E | LinearApiError> =>
24
- effect.pipe(
25
- Effect.timeoutFail({
26
- duration: API_TIMEOUT,
27
- onTimeout: () => new LinearApiError({ message: `${operation} timed out after 30 seconds` }),
28
- }),
29
- Effect.retry(retryPolicy),
30
- );
31
-
32
- const make = Effect.gen(function* () {
33
- const linearClient = yield* LinearClientService;
34
-
35
- const getProjects = (teamId: TeamId): Effect.Effect<ReadonlyArray<Project>, LinearApiError> =>
36
- withRetryAndTimeout(
37
- Effect.gen(function* () {
38
- const client = yield* linearClient.client();
39
- const team = yield* Effect.tryPromise({
40
- try: () => client.team(teamId),
41
- catch: (e) => new LinearApiError({ message: `Failed to fetch team: ${e}`, cause: e }),
42
- });
43
- const projects = yield* Effect.tryPromise({
44
- try: () => team.projects(),
45
- catch: (e) => new LinearApiError({ message: `Failed to fetch projects: ${e}`, cause: e }),
46
- });
47
- return projects.nodes.map((p) => mapProject(p, teamId));
48
- }),
49
- "Fetching projects",
50
- );
51
-
52
- const createProject = (
53
- teamId: TeamId,
54
- input: CreateProjectInput,
55
- ): Effect.Effect<Project, TaskError | LinearApiError> =>
56
- withRetryAndTimeout(
57
- Effect.gen(function* () {
58
- const client = yield* linearClient.client();
59
-
60
- const createInput: { name: string; description?: string; teamIds: string[] } = {
61
- name: input.name,
62
- teamIds: [teamId],
63
- };
64
- if (input.description) {
65
- createInput.description = input.description;
66
- }
67
-
68
- const result = yield* Effect.tryPromise({
69
- try: () => client.createProject(createInput),
70
- catch: (e) => new LinearApiError({ message: `Failed to create project: ${e}`, cause: e }),
71
- });
72
-
73
- if (!result.success) {
74
- return yield* Effect.fail(new TaskError({ message: "Failed to create project" }));
75
- }
76
-
77
- const project = yield* Effect.tryPromise({
78
- try: async () => {
79
- const p = await result.project;
80
- if (!p) throw new Error("Project not returned");
81
- return p;
82
- },
83
- catch: (e) =>
84
- new LinearApiError({ message: `Failed to get created project: ${e}`, cause: e }),
85
- });
86
-
87
- return mapProject(project, teamId);
88
- }),
89
- "Creating project",
90
- );
91
-
92
- return {
93
- getProjects,
94
- createProject,
95
- };
96
- });
97
-
98
- export const ProjectRepositoryLive = Layer.effect(ProjectRepository, make);
@@ -1,101 +0,0 @@
1
- import * as Effect from "effect/Effect";
2
- import * as Layer from "effect/Layer";
3
- import * as Schedule from "effect/Schedule";
4
- import * as Duration from "effect/Duration";
5
- import { TeamRepository, type CreateTeamInput } from "../../../ports/TeamRepository.js";
6
- import { LinearClientService } from "./LinearClient.js";
7
- import { Team, type TeamId } from "../../../domain/Task.js";
8
- import { LinearApiError, TaskError } from "../../../domain/Errors.js";
9
- import { mapTeam } from "./Mapper.js";
10
-
11
- // Retry policy: exponential backoff with max 3 retries
12
- const retryPolicy = Schedule.intersect(
13
- Schedule.exponential(Duration.millis(100)),
14
- Schedule.recurs(3),
15
- );
16
-
17
- // Timeout for API calls: 30 seconds
18
- const API_TIMEOUT = Duration.seconds(30);
19
-
20
- const withRetryAndTimeout = <A, E>(
21
- effect: Effect.Effect<A, E>,
22
- operation: string,
23
- ): Effect.Effect<A, E | LinearApiError> =>
24
- effect.pipe(
25
- Effect.timeoutFail({
26
- duration: API_TIMEOUT,
27
- onTimeout: () => new LinearApiError({ message: `${operation} timed out after 30 seconds` }),
28
- }),
29
- Effect.retry(retryPolicy),
30
- );
31
-
32
- const make = Effect.gen(function* () {
33
- const linearClient = yield* LinearClientService;
34
-
35
- const getTeams = (): Effect.Effect<ReadonlyArray<Team>, LinearApiError> =>
36
- withRetryAndTimeout(
37
- Effect.gen(function* () {
38
- const client = yield* linearClient.client();
39
- const teams = yield* Effect.tryPromise({
40
- try: () => client.teams(),
41
- catch: (e) => new LinearApiError({ message: `Failed to fetch teams: ${e}`, cause: e }),
42
- });
43
- return teams.nodes.map(mapTeam);
44
- }),
45
- "Fetching teams",
46
- );
47
-
48
- const getTeam = (id: TeamId): Effect.Effect<Team, LinearApiError> =>
49
- withRetryAndTimeout(
50
- Effect.gen(function* () {
51
- const client = yield* linearClient.client();
52
- const team = yield* Effect.tryPromise({
53
- try: () => client.team(id),
54
- catch: (e) => new LinearApiError({ message: `Failed to fetch team: ${e}`, cause: e }),
55
- });
56
- return mapTeam(team);
57
- }),
58
- "Fetching team",
59
- );
60
-
61
- const createTeam = (input: CreateTeamInput): Effect.Effect<Team, TaskError | LinearApiError> =>
62
- withRetryAndTimeout(
63
- Effect.gen(function* () {
64
- const client = yield* linearClient.client();
65
-
66
- const result = yield* Effect.tryPromise({
67
- try: () =>
68
- client.createTeam({
69
- name: input.name,
70
- key: input.key,
71
- }),
72
- catch: (e) => new LinearApiError({ message: `Failed to create team: ${e}`, cause: e }),
73
- });
74
-
75
- if (!result.success) {
76
- return yield* Effect.fail(new TaskError({ message: "Failed to create team" }));
77
- }
78
-
79
- const team = yield* Effect.tryPromise({
80
- try: async () => {
81
- const t = await result.team;
82
- if (!t) throw new Error("Team not returned");
83
- return t;
84
- },
85
- catch: (e) =>
86
- new LinearApiError({ message: `Failed to get created team: ${e}`, cause: e }),
87
- });
88
-
89
- return mapTeam(team);
90
- }),
91
- "Creating team",
92
- );
93
-
94
- return {
95
- getTeams,
96
- getTeam,
97
- createTeam,
98
- };
99
- });
100
-
101
- export const TeamRepositoryLive = Layer.effect(TeamRepository, make);
@@ -1,63 +0,0 @@
1
- import * as Command from "@effect/cli/Command";
2
- import * as Args from "@effect/cli/Args";
3
- import * as Options from "@effect/cli/Options";
4
- import * as Effect from "effect/Effect";
5
- import * as Console from "effect/Console";
6
- import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
7
- import { IssueRepository } from "../../../../ports/IssueRepository.js";
8
- import type { TaskId } from "../../../../domain/Task.js";
9
-
10
- const blockerArg = Args.text({ name: "blocker" }).pipe(
11
- Args.withDescription("Task that is blocking (e.g., ENG-123)"),
12
- );
13
-
14
- const blockedArg = Args.text({ name: "blocked" }).pipe(
15
- Args.withDescription("Task that is blocked (e.g., ENG-456)"),
16
- );
17
-
18
- const jsonOption = Options.boolean("json").pipe(
19
- Options.withDescription("Output as JSON"),
20
- Options.withDefault(false),
21
- );
22
-
23
- export const blockCommand = Command.make(
24
- "block",
25
- { blocker: blockerArg, blocked: blockedArg, json: jsonOption },
26
- ({ blocker, blocked, json }) =>
27
- Effect.gen(function* () {
28
- const config = yield* ConfigRepository;
29
- const issueRepo = yield* IssueRepository;
30
-
31
- yield* config.load(); // Ensure initialized
32
-
33
- // Get both tasks to get their IDs
34
- const blockerTask = yield* issueRepo
35
- .getTaskByIdentifier(blocker)
36
- .pipe(Effect.catchTag("TaskNotFoundError", () => issueRepo.getTask(blocker as TaskId)));
37
-
38
- const blockedTask = yield* issueRepo
39
- .getTaskByIdentifier(blocked)
40
- .pipe(Effect.catchTag("TaskNotFoundError", () => issueRepo.getTask(blocked as TaskId)));
41
-
42
- // Add the blocking relationship
43
- yield* issueRepo.addBlocker(blockedTask.id, blockerTask.id);
44
-
45
- if (json) {
46
- yield* Console.log(
47
- JSON.stringify({
48
- status: "blocked",
49
- blocker: {
50
- id: blockerTask.id,
51
- identifier: blockerTask.identifier,
52
- },
53
- blocked: {
54
- id: blockedTask.id,
55
- identifier: blockedTask.identifier,
56
- },
57
- }),
58
- );
59
- } else {
60
- yield* Console.log(`${blockerTask.identifier} now blocks ${blockedTask.identifier}`);
61
- }
62
- }),
63
- );
@@ -1,61 +0,0 @@
1
- import * as Command from "@effect/cli/Command";
2
- import * as Options from "@effect/cli/Options";
3
- import * as Effect from "effect/Effect";
4
- import * as Console from "effect/Console";
5
- import * as Option from "effect/Option";
6
- import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
7
- import { IssueRepository } from "../../../../ports/IssueRepository.js";
8
- import type { Task } from "../../../../domain/Task.js";
9
-
10
- const jsonOption = Options.boolean("json").pipe(
11
- Options.withDescription("Output as JSON"),
12
- Options.withDefault(false),
13
- );
14
-
15
- const formatBlockedTask = (task: Task): string[] => {
16
- const lines: string[] = [];
17
- lines.push(`${task.identifier}: ${task.title}`);
18
- lines.push(` Status: ${task.state.name}`);
19
- lines.push(` Priority: ${task.priority}`);
20
- if (task.blockedBy.length > 0) {
21
- lines.push(` Blocked by: ${task.blockedBy.join(", ")}`);
22
- }
23
- return lines;
24
- };
25
-
26
- export const blockedCommand = Command.make("blocked", { json: jsonOption }, ({ json }) =>
27
- Effect.gen(function* () {
28
- const config = yield* ConfigRepository;
29
- const issueRepo = yield* IssueRepository;
30
-
31
- const cfg = yield* config.load();
32
- const projectId = Option.getOrUndefined(cfg.linear.projectId);
33
- const blockedTasks = yield* issueRepo.getBlockedTasks(cfg.linear.teamId, projectId);
34
-
35
- if (json) {
36
- const output = blockedTasks.map((t) => ({
37
- id: t.id,
38
- identifier: t.identifier,
39
- title: t.title,
40
- priority: t.priority,
41
- state: t.state.name,
42
- stateType: t.state.type,
43
- blockedBy: t.blockedBy,
44
- url: t.url,
45
- }));
46
- yield* Console.log(JSON.stringify(output, null, 2));
47
- } else {
48
- if (blockedTasks.length === 0) {
49
- yield* Console.log("No blocked tasks found.");
50
- } else {
51
- yield* Console.log(`Found ${blockedTasks.length} blocked task(s):\n`);
52
- for (const task of blockedTasks) {
53
- for (const line of formatBlockedTask(task)) {
54
- yield* Console.log(line);
55
- }
56
- yield* Console.log("");
57
- }
58
- }
59
- }
60
- }),
61
- );
@@ -1,83 +0,0 @@
1
- import * as Command from "@effect/cli/Command";
2
- import * as Args from "@effect/cli/Args";
3
- import * as Options from "@effect/cli/Options";
4
- import * as Effect from "effect/Effect";
5
- import * as Console from "effect/Console";
6
- import * as Option from "effect/Option";
7
- import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
8
- import { IssueRepository } from "../../../../ports/IssueRepository.js";
9
- import { CreateTaskInput, Priority, TaskType } from "../../../../domain/Task.js";
10
-
11
- const titleArg = Args.text({ name: "title" }).pipe(Args.withDescription("Task title"));
12
-
13
- const descriptionOption = Options.text("description").pipe(
14
- Options.withAlias("d"),
15
- Options.withDescription("Task description"),
16
- Options.optional,
17
- );
18
-
19
- const priorityOption = Options.choice("priority", ["urgent", "high", "medium", "low", "none"]).pipe(
20
- Options.withAlias("p"),
21
- Options.withDescription("Task priority"),
22
- Options.withDefault("medium" as const),
23
- );
24
-
25
- const typeOption = Options.choice("type", ["bug", "feature", "task", "epic", "chore"]).pipe(
26
- Options.withAlias("t"),
27
- Options.withDescription("Task type"),
28
- Options.withDefault("task" as const),
29
- );
30
-
31
- const jsonOption = Options.boolean("json").pipe(
32
- Options.withDescription("Output as JSON"),
33
- Options.withDefault(false),
34
- );
35
-
36
- export const createCommand = Command.make(
37
- "create",
38
- {
39
- title: titleArg,
40
- description: descriptionOption,
41
- priority: priorityOption,
42
- type: typeOption,
43
- json: jsonOption,
44
- },
45
- ({ title, description, priority, type, json }) =>
46
- Effect.gen(function* () {
47
- const config = yield* ConfigRepository;
48
- const issueRepo = yield* IssueRepository;
49
-
50
- const cfg = yield* config.load();
51
-
52
- const input = new CreateTaskInput({
53
- title,
54
- description: Option.isSome(description) ? Option.some(description.value) : Option.none(),
55
- priority: priority as Priority,
56
- type: type as TaskType,
57
- projectId: cfg.linear.projectId,
58
- });
59
-
60
- const task = yield* issueRepo.createTask(cfg.linear.teamId, input);
61
-
62
- if (json) {
63
- yield* Console.log(
64
- JSON.stringify({
65
- status: "created",
66
- task: {
67
- id: task.id,
68
- identifier: task.identifier,
69
- title: task.title,
70
- priority: task.priority,
71
- state: task.state.name,
72
- url: task.url,
73
- },
74
- }),
75
- );
76
- } else {
77
- yield* Console.log(`Created: ${task.identifier} - ${task.title}`);
78
- yield* Console.log(`Priority: ${task.priority}`);
79
- yield* Console.log(`URL: ${task.url}`);
80
- yield* Console.log(`\nRun 'ship start ${task.identifier}' to begin work.`);
81
- }
82
- }),
83
- );
@@ -1,82 +0,0 @@
1
- import * as Command from "@effect/cli/Command";
2
- import * as Args from "@effect/cli/Args";
3
- import * as Options from "@effect/cli/Options";
4
- import * as Effect from "effect/Effect";
5
- import * as Console from "effect/Console";
6
- import * as Option from "effect/Option";
7
- import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
8
- import { IssueRepository } from "../../../../ports/IssueRepository.js";
9
- import { UpdateTaskInput, type TaskId } from "../../../../domain/Task.js";
10
-
11
- const taskIdArg = Args.text({ name: "task-id" }).pipe(
12
- Args.withDescription("Task identifier (e.g., ENG-123)"),
13
- );
14
-
15
- const reasonOption = Options.text("reason").pipe(
16
- Options.withAlias("r"),
17
- Options.withDescription("Reason or summary of completion"),
18
- Options.optional,
19
- );
20
-
21
- const jsonOption = Options.boolean("json").pipe(
22
- Options.withDescription("Output as JSON"),
23
- Options.withDefault(false),
24
- );
25
-
26
- export const doneCommand = Command.make(
27
- "done",
28
- { taskId: taskIdArg, reason: reasonOption, json: jsonOption },
29
- ({ taskId, reason, json }) =>
30
- Effect.gen(function* () {
31
- const config = yield* ConfigRepository;
32
- const issueRepo = yield* IssueRepository;
33
-
34
- yield* config.load(); // Ensure initialized
35
-
36
- // Get the task
37
- const task = yield* issueRepo
38
- .getTaskByIdentifier(taskId)
39
- .pipe(Effect.catchTag("TaskNotFoundError", () => issueRepo.getTask(taskId as TaskId)));
40
-
41
- // Check if already done (Linear's "completed" or "canceled" state type)
42
- if (task.isDone) {
43
- if (json) {
44
- yield* Console.log(JSON.stringify({ status: "already_done", task: taskId }));
45
- } else {
46
- yield* Console.log(`Task ${task.identifier} is already done (${task.state.name}).`);
47
- }
48
- return;
49
- }
50
-
51
- // Update status to done
52
- const updateInput = new UpdateTaskInput({
53
- title: Option.none(),
54
- description: Option.none(),
55
- status: Option.some("done"),
56
- priority: Option.none(),
57
- });
58
-
59
- const updatedTask = yield* issueRepo.updateTask(task.id, updateInput);
60
-
61
- if (json) {
62
- yield* Console.log(
63
- JSON.stringify({
64
- status: "completed",
65
- task: {
66
- id: updatedTask.id,
67
- identifier: updatedTask.identifier,
68
- title: updatedTask.title,
69
- state: updatedTask.state.name,
70
- },
71
- reason: Option.getOrNull(reason),
72
- }),
73
- );
74
- } else {
75
- yield* Console.log(`Completed: ${updatedTask.identifier} - ${updatedTask.title}`);
76
- if (Option.isSome(reason)) {
77
- yield* Console.log(`Reason: ${reason.value}`);
78
- }
79
- yield* Console.log(`\nRun 'ship ready' to see the next available task.`);
80
- }
81
- }),
82
- );
@@ -1,194 +0,0 @@
1
- import * as Command from "@effect/cli/Command";
2
- import * as Options from "@effect/cli/Options";
3
- import * as Effect from "effect/Effect";
4
- import * as Option from "effect/Option";
5
- import * as clack from "@clack/prompts";
6
- import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
7
- import { AuthService } from "../../../../ports/AuthService.js";
8
- import { TeamRepository } from "../../../../ports/TeamRepository.js";
9
- import { ProjectRepository } from "../../../../ports/ProjectRepository.js";
10
- import { LinearConfig } from "../../../../domain/Config.js";
11
- import type { Team, Project } from "../../../../domain/Task.js";
12
-
13
- const teamOption = Options.text("team").pipe(
14
- Options.withAlias("t"),
15
- Options.withDescription("Team ID or key to use"),
16
- Options.optional,
17
- );
18
-
19
- const projectOption = Options.text("project").pipe(
20
- Options.withAlias("p"),
21
- Options.withDescription("Project ID or name to use"),
22
- Options.optional,
23
- );
24
-
25
- export const initCommand = Command.make(
26
- "init",
27
- { team: teamOption, project: projectOption },
28
- ({ team, project }) =>
29
- Effect.gen(function* () {
30
- const config = yield* ConfigRepository;
31
- const auth = yield* AuthService;
32
- const teamRepo = yield* TeamRepository;
33
- const projectRepo = yield* ProjectRepository;
34
-
35
- clack.intro("ship init");
36
-
37
- // Check if already initialized
38
- const exists = yield* config.exists();
39
- if (exists) {
40
- const partial = yield* config.loadPartial();
41
- if (Option.isSome(partial.auth) && Option.isSome(partial.linear)) {
42
- clack.note(
43
- `Team: ${partial.linear.value.teamKey}${Option.isSome(partial.linear.value.projectId) ? `\nProject: ${partial.linear.value.projectId.value}` : ""}`,
44
- "Already initialized",
45
- );
46
- clack.outro("Run 'ship ready' to see available tasks.");
47
- return;
48
- }
49
- }
50
-
51
- // Step 1: Authenticate if needed
52
- const isAuth = yield* auth.isAuthenticated();
53
- if (!isAuth) {
54
- clack.note(
55
- "Create a personal API key at:\nhttps://linear.app/settings/api",
56
- "Linear Authentication",
57
- );
58
-
59
- const apiKey = yield* Effect.tryPromise({
60
- try: () =>
61
- clack.text({
62
- message: "Paste your API key",
63
- placeholder: "lin_api_...",
64
- validate: (value) => {
65
- if (!value) return "API key is required";
66
- if (!value.startsWith("lin_api_")) return "API key should start with lin_api_";
67
- },
68
- }),
69
- catch: () => new Error("Prompt cancelled"),
70
- });
71
-
72
- if (clack.isCancel(apiKey)) {
73
- clack.cancel("Setup cancelled");
74
- return;
75
- }
76
-
77
- const spinner = clack.spinner();
78
- spinner.start("Validating API key...");
79
-
80
- yield* auth
81
- .saveApiKey(apiKey as string)
82
- .pipe(Effect.tapError(() => Effect.sync(() => spinner.stop("Invalid API key"))));
83
-
84
- spinner.stop("Authenticated");
85
- } else {
86
- clack.log.success("Already authenticated");
87
- }
88
-
89
- // Step 2: Get teams
90
- const teamSpinner = clack.spinner();
91
- teamSpinner.start("Fetching teams...");
92
- const teams = yield* teamRepo.getTeams();
93
- teamSpinner.stop("Teams loaded");
94
-
95
- if (teams.length === 0) {
96
- clack.log.error("No teams found. Please create a team in Linear first.");
97
- clack.outro("Setup incomplete");
98
- return;
99
- }
100
-
101
- // Select team
102
- let selectedTeam: Team;
103
- if (Option.isSome(team)) {
104
- const found = teams.find((t) => t.id === team.value || t.key === team.value);
105
- if (!found) {
106
- clack.log.error(`Team '${team.value}' not found.`);
107
- clack.note(teams.map((t) => `${t.key} - ${t.name}`).join("\n"), "Available teams");
108
- clack.outro("Setup incomplete");
109
- return;
110
- }
111
- selectedTeam = found;
112
- clack.log.success(`Using team: ${selectedTeam.key}`);
113
- } else if (teams.length === 1) {
114
- selectedTeam = teams[0]!;
115
- clack.log.success(`Using team: ${selectedTeam.key} - ${selectedTeam.name}`);
116
- } else {
117
- const teamChoice = yield* Effect.tryPromise({
118
- try: () =>
119
- clack.select({
120
- message: "Select a team",
121
- options: teams.map((t) => ({
122
- value: t.id,
123
- label: `${t.key} - ${t.name}`,
124
- })),
125
- }),
126
- catch: () => new Error("Prompt cancelled"),
127
- });
128
-
129
- if (clack.isCancel(teamChoice)) {
130
- clack.cancel("Setup cancelled");
131
- return;
132
- }
133
-
134
- selectedTeam = teams.find((t) => t.id === teamChoice)!;
135
- }
136
-
137
- // Step 3: Get projects (optional)
138
- const projectSpinner = clack.spinner();
139
- projectSpinner.start("Fetching projects...");
140
- const projects = yield* projectRepo.getProjects(selectedTeam.id);
141
- projectSpinner.stop("Projects loaded");
142
-
143
- let selectedProject: Project | undefined;
144
- if (Option.isSome(project)) {
145
- selectedProject = projects.find((p) => p.id === project.value || p.name === project.value);
146
- if (!selectedProject) {
147
- clack.log.warn(
148
- `Project '${project.value}' not found, continuing without project filter.`,
149
- );
150
- }
151
- } else if (projects.length > 0) {
152
- const projectChoice = yield* Effect.tryPromise({
153
- try: () =>
154
- clack.select({
155
- message: "Select a project (optional)",
156
- options: [
157
- { value: null, label: "No project filter" },
158
- ...projects.map((p) => ({
159
- value: p.id,
160
- label: p.name,
161
- })),
162
- ],
163
- }),
164
- catch: () => new Error("Prompt cancelled"),
165
- });
166
-
167
- if (clack.isCancel(projectChoice)) {
168
- clack.cancel("Setup cancelled");
169
- return;
170
- }
171
-
172
- if (projectChoice) {
173
- selectedProject = projects.find((p) => p.id === projectChoice);
174
- }
175
- }
176
-
177
- // Step 4: Save config and update .gitignore
178
- const linearConfig = new LinearConfig({
179
- teamId: selectedTeam.id,
180
- teamKey: selectedTeam.key,
181
- projectId: selectedProject ? Option.some(selectedProject.id) : Option.none(),
182
- });
183
-
184
- yield* config.saveLinear(linearConfig);
185
- yield* config.ensureGitignore();
186
-
187
- clack.note(
188
- `Team: ${selectedTeam.key} - ${selectedTeam.name}${selectedProject ? `\nProject: ${selectedProject.name}` : ""}`,
189
- "Workspace initialized",
190
- );
191
-
192
- clack.outro("Run 'ship ready' to see available tasks.");
193
- }),
194
- );