@enrichlayer/el-linear 1.4.0 → 1.6.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.
- package/README.md +54 -0
- package/dist/auth/oauth-callback.d.ts +40 -0
- package/dist/auth/oauth-callback.js +142 -0
- package/dist/auth/oauth-client.d.ts +55 -0
- package/dist/auth/oauth-client.js +134 -0
- package/dist/auth/oauth-fs.d.ts +1 -0
- package/dist/auth/oauth-fs.js +29 -0
- package/dist/auth/oauth-headless.d.ts +38 -0
- package/dist/auth/oauth-headless.js +50 -0
- package/dist/auth/oauth-storage.d.ts +51 -0
- package/dist/auth/oauth-storage.js +87 -0
- package/dist/auth/oauth-token.d.ts +70 -0
- package/dist/auth/oauth-token.js +141 -0
- package/dist/auth/token-resolver.d.ts +48 -0
- package/dist/auth/token-resolver.js +95 -0
- package/dist/commands/attachments.js +11 -12
- package/dist/commands/batch.js +15 -14
- package/dist/commands/comments.js +22 -10
- package/dist/commands/cycles.js +5 -4
- package/dist/commands/documents.js +18 -15
- package/dist/commands/embeds.js +4 -6
- package/dist/commands/graphql.js +5 -4
- package/dist/commands/init/aliases.js +0 -14
- package/dist/commands/init/defaults.js +0 -6
- package/dist/commands/init/index.js +22 -12
- package/dist/commands/init/oauth.d.ts +85 -0
- package/dist/commands/init/oauth.js +308 -0
- package/dist/commands/init/shared.js +0 -1
- package/dist/commands/init/token.js +0 -7
- package/dist/commands/init/workspace.js +0 -2
- package/dist/commands/issue-id.js +3 -1
- package/dist/commands/issues.js +87 -46
- package/dist/commands/labels.js +9 -8
- package/dist/commands/profile/migrate-legacy.d.ts +96 -0
- package/dist/commands/profile/migrate-legacy.js +271 -0
- package/dist/commands/profile.js +6 -0
- package/dist/commands/project-milestones.js +13 -12
- package/dist/commands/projects.js +18 -14
- package/dist/commands/read-shortcut.js +8 -7
- package/dist/commands/refs.js +4 -3
- package/dist/commands/releases.js +9 -8
- package/dist/commands/search.js +3 -2
- package/dist/commands/teams.js +3 -2
- package/dist/commands/templates.js +5 -4
- package/dist/commands/users.js +3 -2
- package/dist/config/config.d.ts +18 -0
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/resolver.js +1 -1
- package/dist/main.js +1 -1
- package/dist/utils/auth.js +8 -0
- package/dist/utils/download-uploads.d.ts +2 -1
- package/dist/utils/download-uploads.js +2 -4
- package/dist/utils/file-service.d.ts +26 -2
- package/dist/utils/file-service.js +27 -5
- package/dist/utils/footer.d.ts +19 -0
- package/dist/utils/footer.js +27 -0
- package/dist/utils/gdoc-parser.js +1 -1
- package/dist/utils/graphql-attachments-service.d.ts +1 -1
- package/dist/utils/graphql-attachments-service.js +2 -2
- package/dist/utils/graphql-documents-service.d.ts +1 -1
- package/dist/utils/graphql-documents-service.js +2 -2
- package/dist/utils/graphql-service.d.ts +18 -3
- package/dist/utils/graphql-service.js +26 -11
- package/dist/utils/legacy-config-detection.d.ts +47 -0
- package/dist/utils/legacy-config-detection.js +90 -0
- package/dist/utils/linear-service.d.ts +18 -3
- package/dist/utils/linear-service.js +21 -6
- package/dist/utils/markdown-prosemirror.js +16 -9
- package/dist/utils/migration-hint.d.ts +46 -0
- package/dist/utils/migration-hint.js +90 -0
- package/dist/utils/root-opts.d.ts +11 -0
- package/dist/utils/root-opts.js +13 -0
- package/dist/utils/validators.d.ts +7 -0
- package/dist/utils/validators.js +14 -14
- package/package.json +1 -1
package/dist/commands/labels.js
CHANGED
|
@@ -3,11 +3,12 @@ import { CREATE_LABEL_MUTATION, FIND_PARENT_LABEL_QUERY, RESTORE_LABEL_MUTATION,
|
|
|
3
3
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
4
4
|
import { createLinearService } from "../utils/linear-service.js";
|
|
5
5
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
6
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
6
7
|
import { validateHexColor } from "../utils/validators.js";
|
|
7
8
|
async function handleCreateLabel(name, options, command) {
|
|
8
|
-
const rootOpts = command
|
|
9
|
+
const rootOpts = getRootOpts(command);
|
|
9
10
|
const teamId = resolveTeam(options.team);
|
|
10
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
11
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
11
12
|
const input = { name, teamId };
|
|
12
13
|
if (options.color) {
|
|
13
14
|
input.color = validateHexColor(options.color);
|
|
@@ -37,8 +38,8 @@ async function handleCreateLabel(name, options, command) {
|
|
|
37
38
|
outputSuccess(createResult.issueLabel);
|
|
38
39
|
}
|
|
39
40
|
async function handleRetireLabel(labelId, _options, command) {
|
|
40
|
-
const rootOpts = command
|
|
41
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
41
|
+
const rootOpts = getRootOpts(command);
|
|
42
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
42
43
|
const result = await graphQLService.rawRequest(RETIRE_LABEL_MUTATION, {
|
|
43
44
|
id: labelId,
|
|
44
45
|
});
|
|
@@ -49,8 +50,8 @@ async function handleRetireLabel(labelId, _options, command) {
|
|
|
49
50
|
outputSuccess(retireResult.issueLabel);
|
|
50
51
|
}
|
|
51
52
|
async function handleRestoreLabel(labelId, _options, command) {
|
|
52
|
-
const rootOpts = command
|
|
53
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
53
|
+
const rootOpts = getRootOpts(command);
|
|
54
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
54
55
|
const result = await graphQLService.rawRequest(RESTORE_LABEL_MUTATION, {
|
|
55
56
|
id: labelId,
|
|
56
57
|
});
|
|
@@ -72,9 +73,9 @@ export function setupLabelsCommands(program) {
|
|
|
72
73
|
.option("--team <team>", "filter by team key, name, or ID")
|
|
73
74
|
.option("-l, --limit <number>", "limit results", "100")
|
|
74
75
|
.action(handleAsyncCommand(async (options, command) => {
|
|
75
|
-
const rootOpts = command
|
|
76
|
+
const rootOpts = getRootOpts(command);
|
|
76
77
|
const teamFilter = options.team ? resolveTeam(options.team) : undefined;
|
|
77
|
-
const service = createLinearService(rootOpts);
|
|
78
|
+
const service = await createLinearService(rootOpts);
|
|
78
79
|
const result = await service.getLabels(teamFilter, Number.parseInt(options.limit, 10));
|
|
79
80
|
outputSuccess({
|
|
80
81
|
data: result.labels,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `el-linear profile migrate-legacy` — one-shot migration from the legacy
|
|
3
|
+
* single-file config layout (`~/.config/el-linear/{token,config.json}`) to
|
|
4
|
+
* the named-profiles layout introduced in 1.4 (`~/.config/el-linear/
|
|
5
|
+
* profiles/<name>/{token,config.json}`).
|
|
6
|
+
*
|
|
7
|
+
* Why this command exists:
|
|
8
|
+
*
|
|
9
|
+
* When a user upgraded el-linear to >=1.4, their existing single-file
|
|
10
|
+
* `config.json` was preserved verbatim, but the legacy `token` slot was
|
|
11
|
+
* sometimes cleared (depending on how the upgrade was performed) and
|
|
12
|
+
* 1.4 expects per-profile tokens. Result: every command failed with
|
|
13
|
+
* "Authentication required" while the rich legacy config (member
|
|
14
|
+
* aliases, brand rules, default labels) sat right there on disk with
|
|
15
|
+
* no documented migration path.
|
|
16
|
+
*
|
|
17
|
+
* Design constraints:
|
|
18
|
+
*
|
|
19
|
+
* - **Each step is independently idempotent.** Re-running the command
|
|
20
|
+
* after a successful migration is a no-op — config + token files match,
|
|
21
|
+
* active-profile already points at the right name. Re-running after a
|
|
22
|
+
* partial failure picks up where it left off without `--force`.
|
|
23
|
+
*
|
|
24
|
+
* - **Validate before writing.** A token that doesn't pass `viewer { ... }`
|
|
25
|
+
* never lands on disk. The validate-then-write order means an interrupted
|
|
26
|
+
* migration can't leave a dud token in a freshly-created profile dir.
|
|
27
|
+
*
|
|
28
|
+
* - **Legacy preservation.** We never delete the legacy `config.json` or
|
|
29
|
+
* `token` — the user gets a rollback path. A one-line stdout hint says
|
|
30
|
+
* so explicitly.
|
|
31
|
+
*
|
|
32
|
+
* - **`--force` is opt-in destruction.** When the destination profile
|
|
33
|
+
* already has a config.json or token that *differs* from the source,
|
|
34
|
+
* the command refuses by default with a clear diff hint. `--force`
|
|
35
|
+
* overwrites; `--yes` skips the interactive confirm. Both are
|
|
36
|
+
* required-together for unattended (CI / scripted) overwrites.
|
|
37
|
+
*/
|
|
38
|
+
import { confirm, input, password } from "@inquirer/prompts";
|
|
39
|
+
import type { Command } from "commander";
|
|
40
|
+
import { CONFIG_PATH, PROFILES_DIR, TOKEN_PATH } from "../../config/paths.js";
|
|
41
|
+
export interface MigrateLegacyOptions {
|
|
42
|
+
/** Target profile name. Defaults to "default" when omitted. */
|
|
43
|
+
name?: string;
|
|
44
|
+
/** Path to a file containing the API token (whitespace trimmed). */
|
|
45
|
+
tokenFrom?: string;
|
|
46
|
+
/** Overwrite existing profile config.json/token even if they differ. */
|
|
47
|
+
force?: boolean;
|
|
48
|
+
/** Skip interactive confirmation when --force is needed. */
|
|
49
|
+
yes?: boolean;
|
|
50
|
+
/** Skip the interactive name prompt — use whatever `name` resolved to. */
|
|
51
|
+
skipPrompt?: boolean;
|
|
52
|
+
}
|
|
53
|
+
export interface MigrateLegacyDeps {
|
|
54
|
+
/**
|
|
55
|
+
* Hook around the `viewer` validation call so tests can short-circuit
|
|
56
|
+
* the GraphQL roundtrip. Production wiring uses the real
|
|
57
|
+
* `validateToken` from `init/token.ts`.
|
|
58
|
+
*/
|
|
59
|
+
validateToken?: (token: string) => Promise<{
|
|
60
|
+
id: string;
|
|
61
|
+
organization: {
|
|
62
|
+
urlKey: string;
|
|
63
|
+
name: string;
|
|
64
|
+
};
|
|
65
|
+
displayName: string;
|
|
66
|
+
email: string;
|
|
67
|
+
}>;
|
|
68
|
+
/** stdout writer (for the success line + "kept for rollback" hint). */
|
|
69
|
+
stdout?: {
|
|
70
|
+
write: (chunk: string) => void;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Inquirer-based prompts. Tests inject deterministic responses so we
|
|
74
|
+
* don't need a TTY.
|
|
75
|
+
*/
|
|
76
|
+
prompts?: {
|
|
77
|
+
input: typeof input;
|
|
78
|
+
password: typeof password;
|
|
79
|
+
confirm: typeof confirm;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Register `el-linear profile migrate-legacy` under the parent `profile` command. */
|
|
83
|
+
export declare function registerMigrateLegacy(profile: Command): void;
|
|
84
|
+
/**
|
|
85
|
+
* Top-level orchestrator. Each helper below is independently idempotent;
|
|
86
|
+
* this function just sequences them and prints the final ✓ banner.
|
|
87
|
+
*
|
|
88
|
+
* Exit semantics:
|
|
89
|
+
*
|
|
90
|
+
* - Missing legacy config → `process.exit(1)` (user error, nothing to do).
|
|
91
|
+
* - Refused overwrite (no --force) → throws — handled by handleAsyncCommand
|
|
92
|
+
* elsewhere in the CLI and surfaces as a structured JSON error on stdout.
|
|
93
|
+
* - Token validation failure → throws (no on-disk change has happened yet).
|
|
94
|
+
*/
|
|
95
|
+
export declare function runMigrateLegacy(options: MigrateLegacyOptions, deps?: MigrateLegacyDeps): Promise<void>;
|
|
96
|
+
export { CONFIG_PATH, PROFILES_DIR, TOKEN_PATH };
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `el-linear profile migrate-legacy` — one-shot migration from the legacy
|
|
3
|
+
* single-file config layout (`~/.config/el-linear/{token,config.json}`) to
|
|
4
|
+
* the named-profiles layout introduced in 1.4 (`~/.config/el-linear/
|
|
5
|
+
* profiles/<name>/{token,config.json}`).
|
|
6
|
+
*
|
|
7
|
+
* Why this command exists:
|
|
8
|
+
*
|
|
9
|
+
* When a user upgraded el-linear to >=1.4, their existing single-file
|
|
10
|
+
* `config.json` was preserved verbatim, but the legacy `token` slot was
|
|
11
|
+
* sometimes cleared (depending on how the upgrade was performed) and
|
|
12
|
+
* 1.4 expects per-profile tokens. Result: every command failed with
|
|
13
|
+
* "Authentication required" while the rich legacy config (member
|
|
14
|
+
* aliases, brand rules, default labels) sat right there on disk with
|
|
15
|
+
* no documented migration path.
|
|
16
|
+
*
|
|
17
|
+
* Design constraints:
|
|
18
|
+
*
|
|
19
|
+
* - **Each step is independently idempotent.** Re-running the command
|
|
20
|
+
* after a successful migration is a no-op — config + token files match,
|
|
21
|
+
* active-profile already points at the right name. Re-running after a
|
|
22
|
+
* partial failure picks up where it left off without `--force`.
|
|
23
|
+
*
|
|
24
|
+
* - **Validate before writing.** A token that doesn't pass `viewer { ... }`
|
|
25
|
+
* never lands on disk. The validate-then-write order means an interrupted
|
|
26
|
+
* migration can't leave a dud token in a freshly-created profile dir.
|
|
27
|
+
*
|
|
28
|
+
* - **Legacy preservation.** We never delete the legacy `config.json` or
|
|
29
|
+
* `token` — the user gets a rollback path. A one-line stdout hint says
|
|
30
|
+
* so explicitly.
|
|
31
|
+
*
|
|
32
|
+
* - **`--force` is opt-in destruction.** When the destination profile
|
|
33
|
+
* already has a config.json or token that *differs* from the source,
|
|
34
|
+
* the command refuses by default with a clear diff hint. `--force`
|
|
35
|
+
* overwrites; `--yes` skips the interactive confirm. Both are
|
|
36
|
+
* required-together for unattended (CI / scripted) overwrites.
|
|
37
|
+
*/
|
|
38
|
+
import { promises as fsp } from "node:fs";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { confirm, input, password } from "@inquirer/prompts";
|
|
41
|
+
import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, PROFILES_DIR, profilePaths, TOKEN_PATH, } from "../../config/paths.js";
|
|
42
|
+
import { sanitizeForLog, validateToken } from "../init/token.js";
|
|
43
|
+
const DEFAULT_PROMPTS = { input, password, confirm };
|
|
44
|
+
/** Register `el-linear profile migrate-legacy` under the parent `profile` command. */
|
|
45
|
+
export function registerMigrateLegacy(profile) {
|
|
46
|
+
profile
|
|
47
|
+
.command("migrate-legacy")
|
|
48
|
+
.description("Copy the legacy ~/.config/el-linear/{config.json,token} into a named profile so >=1.4 commands work again.")
|
|
49
|
+
.option("--name <name>", "Target profile name. Defaults to `default`.", "default")
|
|
50
|
+
.option("--token-from <path>", "Read the API token from this file instead of prompting.")
|
|
51
|
+
.option("--force", "Overwrite an existing per-profile config.json or token even when contents differ.")
|
|
52
|
+
.option("--yes", "Skip interactive confirmations (still respects --force semantics).")
|
|
53
|
+
.action(async (opts) => {
|
|
54
|
+
await runMigrateLegacy({
|
|
55
|
+
name: opts.name,
|
|
56
|
+
tokenFrom: opts.tokenFrom,
|
|
57
|
+
force: opts.force === true,
|
|
58
|
+
yes: opts.yes === true,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Top-level orchestrator. Each helper below is independently idempotent;
|
|
64
|
+
* this function just sequences them and prints the final ✓ banner.
|
|
65
|
+
*
|
|
66
|
+
* Exit semantics:
|
|
67
|
+
*
|
|
68
|
+
* - Missing legacy config → `process.exit(1)` (user error, nothing to do).
|
|
69
|
+
* - Refused overwrite (no --force) → throws — handled by handleAsyncCommand
|
|
70
|
+
* elsewhere in the CLI and surfaces as a structured JSON error on stdout.
|
|
71
|
+
* - Token validation failure → throws (no on-disk change has happened yet).
|
|
72
|
+
*/
|
|
73
|
+
export async function runMigrateLegacy(options, deps = {}) {
|
|
74
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
75
|
+
const prompts = deps.prompts ?? DEFAULT_PROMPTS;
|
|
76
|
+
const validate = deps.validateToken ?? validateToken;
|
|
77
|
+
// 1. Pre-flight: legacy config must exist. There's nothing to migrate
|
|
78
|
+
// on a freshly-onboarded machine and we don't want to silently
|
|
79
|
+
// create an empty profile.
|
|
80
|
+
if (!(await pathExists(CONFIG_PATH))) {
|
|
81
|
+
stdout.write(`Nothing to migrate — no legacy config found at ${CONFIG_PATH}.\n`);
|
|
82
|
+
stdout.write("If this is a fresh install, run `el-linear init` instead.\n");
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
const initialName = (options.name ?? "default").trim() || "default";
|
|
86
|
+
let name = initialName;
|
|
87
|
+
// Allow an interactive override of the target name unless --yes was passed
|
|
88
|
+
// (scripted) or --name was explicitly set to something other than the
|
|
89
|
+
// default. Keeps backward compat with the CLI flag while not surprising
|
|
90
|
+
// CI runs.
|
|
91
|
+
if (!options.skipPrompt && !options.yes && options.name === undefined) {
|
|
92
|
+
const answer = await prompts.input({
|
|
93
|
+
message: "Target profile name:",
|
|
94
|
+
default: name,
|
|
95
|
+
});
|
|
96
|
+
const trimmed = answer.trim();
|
|
97
|
+
if (trimmed.length > 0)
|
|
98
|
+
name = trimmed;
|
|
99
|
+
}
|
|
100
|
+
if (!isSafeName(name)) {
|
|
101
|
+
throw new Error(`Invalid profile name "${name}". Allowed: [a-z0-9_.-], up to 64 chars.`);
|
|
102
|
+
}
|
|
103
|
+
// 2. Token source priority: --token-from > EL_LINEAR_TOKEN > prompt.
|
|
104
|
+
// Validate before writing anything to disk.
|
|
105
|
+
const token = await resolveAndValidateToken(options, prompts, validate);
|
|
106
|
+
// 3. Profile dir.
|
|
107
|
+
const paths = profilePaths(name);
|
|
108
|
+
const profileDir = path.dirname(paths.configPath);
|
|
109
|
+
await fsp.mkdir(profileDir, { recursive: true, mode: 0o700 });
|
|
110
|
+
// 4. Config copy with idempotent + force semantics.
|
|
111
|
+
await copyConfigIntoProfile(paths.configPath, options, prompts);
|
|
112
|
+
// 5. Token write with idempotent + force semantics.
|
|
113
|
+
await writeProfileToken(paths.tokenPath, token, options, prompts);
|
|
114
|
+
// 6. active-profile marker.
|
|
115
|
+
await ensureActiveProfile(name);
|
|
116
|
+
// 7. Legacy preservation hint. We do NOT delete the legacy paths —
|
|
117
|
+
// the user gets a rollback if anything went sideways.
|
|
118
|
+
stdout.write(`legacy ${CONFIG_PATH} kept for rollback; safe to remove later if no longer needed\n`);
|
|
119
|
+
// 8. Final verify against the freshly-written profile token. This is
|
|
120
|
+
// a defensive double-check: the token was already validated above,
|
|
121
|
+
// but verifying *after* the write catches any FS-level surprise
|
|
122
|
+
// (e.g. wrong token landed in the wrong dir on a multi-profile box).
|
|
123
|
+
const onDiskToken = (await fsp.readFile(paths.tokenPath, "utf8")).trim();
|
|
124
|
+
const viewer = await validate(onDiskToken);
|
|
125
|
+
stdout.write(`✓ Migrated. Active profile: ${name}. Workspace: ${viewer.organization.urlKey}\n`);
|
|
126
|
+
}
|
|
127
|
+
// ---- Helpers --------------------------------------------------------
|
|
128
|
+
async function resolveAndValidateToken(options, prompts, validate) {
|
|
129
|
+
if (options.tokenFrom) {
|
|
130
|
+
const raw = await fsp.readFile(options.tokenFrom, "utf8");
|
|
131
|
+
const token = raw.trim();
|
|
132
|
+
if (!token) {
|
|
133
|
+
throw new Error(`Token file ${options.tokenFrom} is empty.`);
|
|
134
|
+
}
|
|
135
|
+
await validate(token);
|
|
136
|
+
return token;
|
|
137
|
+
}
|
|
138
|
+
const envToken = process.env.EL_LINEAR_TOKEN?.trim();
|
|
139
|
+
if (envToken) {
|
|
140
|
+
await validate(envToken);
|
|
141
|
+
return envToken;
|
|
142
|
+
}
|
|
143
|
+
// Interactive: up to three attempts, hidden input. We re-prompt on
|
|
144
|
+
// validation failure rather than aborting so the user can paste a
|
|
145
|
+
// fresh token without re-running the whole command.
|
|
146
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
147
|
+
const candidate = (await prompts.password({
|
|
148
|
+
message: attempt === 0
|
|
149
|
+
? "Linear API token (input hidden):"
|
|
150
|
+
: "Try again (input hidden):",
|
|
151
|
+
mask: "*",
|
|
152
|
+
validate: (s) => s.trim().length > 0 || "Token cannot be empty",
|
|
153
|
+
})).trim();
|
|
154
|
+
try {
|
|
155
|
+
await validate(candidate);
|
|
156
|
+
return candidate;
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
160
|
+
console.log(` ✗ ${sanitizeForLog(raw)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
throw new Error("Could not validate a Linear API token after 3 attempts. Aborting migration.");
|
|
164
|
+
}
|
|
165
|
+
async function copyConfigIntoProfile(destConfigPath, options, prompts) {
|
|
166
|
+
const sourceContent = await fsp.readFile(CONFIG_PATH, "utf8");
|
|
167
|
+
if (!(await pathExists(destConfigPath))) {
|
|
168
|
+
await fsp.writeFile(destConfigPath, sourceContent, {
|
|
169
|
+
mode: 0o644,
|
|
170
|
+
encoding: "utf8",
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const destContent = await fsp.readFile(destConfigPath, "utf8");
|
|
175
|
+
if (destContent === sourceContent) {
|
|
176
|
+
// Idempotent re-run — nothing to do.
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!options.force) {
|
|
180
|
+
throw new Error([
|
|
181
|
+
`Refusing to overwrite ${destConfigPath} — its contents differ from ${CONFIG_PATH}.`,
|
|
182
|
+
"Re-run with --force to overwrite. The legacy file is kept either way; only the per-profile copy changes.",
|
|
183
|
+
`Diff hint: \`diff ${CONFIG_PATH} ${destConfigPath}\``,
|
|
184
|
+
].join("\n"));
|
|
185
|
+
}
|
|
186
|
+
if (!options.yes) {
|
|
187
|
+
const ok = await prompts.confirm({
|
|
188
|
+
message: `Overwrite ${destConfigPath} with the legacy config?`,
|
|
189
|
+
default: false,
|
|
190
|
+
});
|
|
191
|
+
if (!ok) {
|
|
192
|
+
throw new Error("Aborted by user.");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
await fsp.writeFile(destConfigPath, sourceContent, {
|
|
196
|
+
mode: 0o644,
|
|
197
|
+
encoding: "utf8",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
async function writeProfileToken(destTokenPath, token, options, prompts) {
|
|
201
|
+
const newline = `${token}\n`;
|
|
202
|
+
if (!(await pathExists(destTokenPath))) {
|
|
203
|
+
await fsp.writeFile(destTokenPath, newline, {
|
|
204
|
+
mode: 0o600,
|
|
205
|
+
encoding: "utf8",
|
|
206
|
+
});
|
|
207
|
+
// fs.writeFile mode is only honored on file creation; chmod defensively
|
|
208
|
+
// in case a parent process pre-created the file with permissive perms.
|
|
209
|
+
await fsp.chmod(destTokenPath, 0o600);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const existing = (await fsp.readFile(destTokenPath, "utf8")).trim();
|
|
213
|
+
if (existing === token) {
|
|
214
|
+
// Idempotent re-run — already correct; force perms regardless.
|
|
215
|
+
await fsp.chmod(destTokenPath, 0o600);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!options.force) {
|
|
219
|
+
throw new Error([
|
|
220
|
+
`Refusing to overwrite ${destTokenPath} — it contains a different token.`,
|
|
221
|
+
"Re-run with --force to overwrite the per-profile token.",
|
|
222
|
+
].join("\n"));
|
|
223
|
+
}
|
|
224
|
+
if (!options.yes) {
|
|
225
|
+
const ok = await prompts.confirm({
|
|
226
|
+
message: `Overwrite the existing token at ${destTokenPath}?`,
|
|
227
|
+
default: false,
|
|
228
|
+
});
|
|
229
|
+
if (!ok) {
|
|
230
|
+
throw new Error("Aborted by user.");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
await fsp.writeFile(destTokenPath, newline, {
|
|
234
|
+
mode: 0o600,
|
|
235
|
+
encoding: "utf8",
|
|
236
|
+
});
|
|
237
|
+
await fsp.chmod(destTokenPath, 0o600);
|
|
238
|
+
}
|
|
239
|
+
async function ensureActiveProfile(name) {
|
|
240
|
+
await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
241
|
+
let current = null;
|
|
242
|
+
if (await pathExists(ACTIVE_PROFILE_FILE)) {
|
|
243
|
+
current = (await fsp.readFile(ACTIVE_PROFILE_FILE, "utf8")).trim();
|
|
244
|
+
}
|
|
245
|
+
if (current === name)
|
|
246
|
+
return;
|
|
247
|
+
await fsp.writeFile(ACTIVE_PROFILE_FILE, `${name}\n`, {
|
|
248
|
+
mode: 0o644,
|
|
249
|
+
encoding: "utf8",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
async function pathExists(p) {
|
|
253
|
+
try {
|
|
254
|
+
await fsp.access(p);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Profile names land in filesystem paths and the active-profile marker.
|
|
263
|
+
* Same conservative charset as `profile add` to avoid `..` traversal,
|
|
264
|
+
* shell metacharacters, and Unicode lookalikes.
|
|
265
|
+
*/
|
|
266
|
+
function isSafeName(name) {
|
|
267
|
+
return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name);
|
|
268
|
+
}
|
|
269
|
+
// Re-export internals so the integration test (and follow-up commands)
|
|
270
|
+
// can compose them without re-implementing the idempotency rules.
|
|
271
|
+
export { CONFIG_PATH, PROFILES_DIR, TOKEN_PATH };
|
package/dist/commands/profile.js
CHANGED
|
@@ -26,6 +26,7 @@ import { confirm } from "@inquirer/prompts";
|
|
|
26
26
|
import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, PROFILES_DIR, profilePaths, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
|
|
27
27
|
import { outputSuccess, outputWarning } from "../utils/output.js";
|
|
28
28
|
import { runFullWizard } from "./init/index.js";
|
|
29
|
+
import { registerMigrateLegacy } from "./profile/migrate-legacy.js";
|
|
29
30
|
export function setupProfileCommands(program) {
|
|
30
31
|
const profile = program
|
|
31
32
|
.command("profile")
|
|
@@ -72,6 +73,11 @@ export function setupProfileCommands(program) {
|
|
|
72
73
|
.action(async (name, opts) => {
|
|
73
74
|
await runProfileRemove(name, opts.force === true);
|
|
74
75
|
});
|
|
76
|
+
// `el-linear profile migrate-legacy` — registered alongside add/list/etc.
|
|
77
|
+
// Lives in its own module so the multi-step migration logic stays
|
|
78
|
+
// self-contained and unit-testable without dragging in the full
|
|
79
|
+
// profile-management surface.
|
|
80
|
+
registerMigrateLegacy(profile);
|
|
75
81
|
}
|
|
76
82
|
export async function runProfileList() {
|
|
77
83
|
const active = resolveActiveProfile();
|
|
@@ -3,6 +3,7 @@ import { multipleMatchesError, notFoundError, } from "../utils/error-messages.js
|
|
|
3
3
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
4
4
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
5
5
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
6
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
6
7
|
import { isUuid } from "../utils/uuid.js";
|
|
7
8
|
import { validateIsoDate } from "../utils/validators.js";
|
|
8
9
|
async function resolveMilestoneId(milestoneNameOrId, graphQLService, linearService, projectNameOrId) {
|
|
@@ -37,9 +38,9 @@ async function resolveMilestoneId(milestoneNameOrId, graphQLService, linearServi
|
|
|
37
38
|
return nodes[0].id;
|
|
38
39
|
}
|
|
39
40
|
async function handleListMilestones(options, command) {
|
|
40
|
-
const rootOpts = command
|
|
41
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
42
|
-
const linearService = createLinearService(rootOpts);
|
|
41
|
+
const rootOpts = getRootOpts(command);
|
|
42
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
43
|
+
const linearService = await createLinearService(rootOpts);
|
|
43
44
|
const projectId = await linearService.resolveProjectId(options.project);
|
|
44
45
|
const result = await graphQLService.rawRequest(LIST_PROJECT_MILESTONES_QUERY, {
|
|
45
46
|
projectId,
|
|
@@ -51,9 +52,9 @@ async function handleListMilestones(options, command) {
|
|
|
51
52
|
outputSuccess({ data: nodes, meta: { count: nodes.length } });
|
|
52
53
|
}
|
|
53
54
|
async function handleReadMilestone(milestoneIdOrName, options, command) {
|
|
54
|
-
const rootOpts = command
|
|
55
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
56
|
-
const linearService = createLinearService(rootOpts);
|
|
55
|
+
const rootOpts = getRootOpts(command);
|
|
56
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
57
|
+
const linearService = await createLinearService(rootOpts);
|
|
57
58
|
const milestoneId = await resolveMilestoneId(milestoneIdOrName, graphQLService, linearService, options.project);
|
|
58
59
|
const result = await graphQLService.rawRequest(GET_PROJECT_MILESTONE_BY_ID_QUERY, {
|
|
59
60
|
id: milestoneId,
|
|
@@ -62,9 +63,9 @@ async function handleReadMilestone(milestoneIdOrName, options, command) {
|
|
|
62
63
|
outputSuccess(result.projectMilestone);
|
|
63
64
|
}
|
|
64
65
|
async function handleCreateMilestone(name, options, command) {
|
|
65
|
-
const rootOpts = command
|
|
66
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
67
|
-
const linearService = createLinearService(rootOpts);
|
|
66
|
+
const rootOpts = getRootOpts(command);
|
|
67
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
68
|
+
const linearService = await createLinearService(rootOpts);
|
|
68
69
|
const projectId = await linearService.resolveProjectId(options.project);
|
|
69
70
|
if (options.targetDate) {
|
|
70
71
|
validateIsoDate(options.targetDate);
|
|
@@ -82,9 +83,9 @@ async function handleCreateMilestone(name, options, command) {
|
|
|
82
83
|
outputSuccess(createData.projectMilestone);
|
|
83
84
|
}
|
|
84
85
|
async function handleUpdateMilestone(milestoneIdOrName, options, command) {
|
|
85
|
-
const rootOpts = command
|
|
86
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
87
|
-
const linearService = createLinearService(rootOpts);
|
|
86
|
+
const rootOpts = getRootOpts(command);
|
|
87
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
88
|
+
const linearService = await createLinearService(rootOpts);
|
|
88
89
|
const milestoneId = await resolveMilestoneId(milestoneIdOrName, graphQLService, linearService, options.project);
|
|
89
90
|
const updateVars = { id: milestoneId };
|
|
90
91
|
if (options.name !== undefined) {
|
|
@@ -4,6 +4,7 @@ import { createGraphQLService } from "../utils/graphql-service.js";
|
|
|
4
4
|
import { createLinearService } from "../utils/linear-service.js";
|
|
5
5
|
import { logger } from "../utils/logger.js";
|
|
6
6
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
7
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
7
8
|
import { isUuid } from "../utils/uuid.js";
|
|
8
9
|
import { splitList } from "../utils/validators.js";
|
|
9
10
|
function formatProjectsOutput(projects, format, fieldNames) {
|
|
@@ -126,9 +127,10 @@ function formatTeamsOutput(projectUpdate) {
|
|
|
126
127
|
};
|
|
127
128
|
}
|
|
128
129
|
async function handleAddTeam(projectNameOrId, teamInput, _options, command) {
|
|
129
|
-
const rootOpts = command
|
|
130
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
131
|
-
const
|
|
130
|
+
const rootOpts = getRootOpts(command);
|
|
131
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
132
|
+
const linearService = await createLinearService(rootOpts);
|
|
133
|
+
const finalTeamId = await linearService.resolveTeamId(resolveTeam(teamInput));
|
|
132
134
|
const { projectId, currentTeams } = await resolveProjectWithTeams(graphQLService, projectNameOrId);
|
|
133
135
|
const currentTeamIds = currentTeams.map((t) => t.id);
|
|
134
136
|
if (currentTeamIds.includes(finalTeamId)) {
|
|
@@ -149,9 +151,10 @@ async function handleAddTeam(projectNameOrId, teamInput, _options, command) {
|
|
|
149
151
|
outputSuccess(formatTeamsOutput(projectUpdate));
|
|
150
152
|
}
|
|
151
153
|
async function handleAddTeams(projectNameOrId, teamInputs, _options, command) {
|
|
152
|
-
const rootOpts = command
|
|
153
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
154
|
-
const
|
|
154
|
+
const rootOpts = getRootOpts(command);
|
|
155
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
156
|
+
const linearService = await createLinearService(rootOpts);
|
|
157
|
+
const resolvedTeamIds = await Promise.all(teamInputs.map((t) => linearService.resolveTeamId(resolveTeam(t))));
|
|
155
158
|
const { projectId, currentTeams } = await resolveProjectWithTeams(graphQLService, projectNameOrId);
|
|
156
159
|
const currentTeamIds = currentTeams.map((t) => t.id);
|
|
157
160
|
const newTeamIds = resolvedTeamIds.filter((id) => !currentTeamIds.includes(id));
|
|
@@ -181,9 +184,10 @@ async function handleAddTeams(projectNameOrId, teamInputs, _options, command) {
|
|
|
181
184
|
});
|
|
182
185
|
}
|
|
183
186
|
async function handleRemoveTeam(projectNameOrId, teamInput, options, command) {
|
|
184
|
-
const rootOpts = command
|
|
185
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
186
|
-
const
|
|
187
|
+
const rootOpts = getRootOpts(command);
|
|
188
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
189
|
+
const linearService = await createLinearService(rootOpts);
|
|
190
|
+
const finalTeamId = await linearService.resolveTeamId(resolveTeam(teamInput));
|
|
187
191
|
const { projectId, projectName, currentTeams } = await resolveProjectWithTeams(graphQLService, projectNameOrId);
|
|
188
192
|
const currentTeamIds = currentTeams.map((t) => t.id);
|
|
189
193
|
if (!currentTeamIds.includes(finalTeamId)) {
|
|
@@ -220,8 +224,8 @@ async function handleRemoveTeam(projectNameOrId, teamInput, options, command) {
|
|
|
220
224
|
});
|
|
221
225
|
}
|
|
222
226
|
async function handleCreateProject(name, options, command) {
|
|
223
|
-
const rootOpts = command
|
|
224
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
227
|
+
const rootOpts = getRootOpts(command);
|
|
228
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
225
229
|
// Step 1: Check for duplicate projects (case-insensitive)
|
|
226
230
|
const searchResult = await graphQLService.rawRequest(SEARCH_PROJECTS_BY_NAME_QUERY, { name });
|
|
227
231
|
const existing = (searchResult.projects
|
|
@@ -236,7 +240,7 @@ async function handleCreateProject(name, options, command) {
|
|
|
236
240
|
`Use --force to create anyway, or use the existing project.`);
|
|
237
241
|
}
|
|
238
242
|
// Step 2: Resolve team IDs
|
|
239
|
-
const service = createLinearService(rootOpts);
|
|
243
|
+
const service = await createLinearService(rootOpts);
|
|
240
244
|
const teamKeys = options.team ? splitList(options.team) : [];
|
|
241
245
|
const teamIds = [];
|
|
242
246
|
for (const key of teamKeys) {
|
|
@@ -300,8 +304,8 @@ export function setupProjectsCommands(program) {
|
|
|
300
304
|
.option("--format <format>", "output format (json, table, md, csv)", "json")
|
|
301
305
|
.option("--fields <fields>", "columns for table/csv (comma-separated: name,state,progress,teams,lead,targetDate)")
|
|
302
306
|
.action(handleAsyncCommand(async (options, command) => {
|
|
303
|
-
const rootOpts = command
|
|
304
|
-
const service = createLinearService(rootOpts);
|
|
307
|
+
const rootOpts = getRootOpts(command);
|
|
308
|
+
const service = await createLinearService(rootOpts);
|
|
305
309
|
const result = await service.getProjects(Number.parseInt(options.limit, 10));
|
|
306
310
|
const format = options.format;
|
|
307
311
|
if (format === "table" ||
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { getApiToken } from "../utils/auth.js";
|
|
2
1
|
import { downloadLinearUploads } from "../utils/download-uploads.js";
|
|
2
|
+
import { createFileService } from "../utils/file-service.js";
|
|
3
3
|
import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
|
|
4
4
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
5
5
|
import { createLinearService } from "../utils/linear-service.js";
|
|
6
6
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
7
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
7
8
|
/**
|
|
8
9
|
* Issue ID pattern: 1-5 uppercase letters, dash, 1+ digits (e.g. ADM-652, DEV-12).
|
|
9
10
|
*/
|
|
@@ -49,20 +50,20 @@ export function setupReadShortcut(program) {
|
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
52
|
async function readIssues(issueIds, _options, command) {
|
|
52
|
-
const rootOpts = command
|
|
53
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
54
|
-
const linearService = createLinearService(rootOpts);
|
|
53
|
+
const rootOpts = getRootOpts(command);
|
|
54
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
55
|
+
const linearService = await createLinearService(rootOpts);
|
|
55
56
|
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
56
|
-
const
|
|
57
|
+
const fileService = await createFileService(rootOpts);
|
|
57
58
|
if (issueIds.length === 1) {
|
|
58
59
|
const issue = await issuesService.getIssueById(issueIds[0]);
|
|
59
|
-
const resolved = await downloadLinearUploads(issue,
|
|
60
|
+
const resolved = await downloadLinearUploads(issue, fileService);
|
|
60
61
|
outputSuccess(resolved);
|
|
61
62
|
}
|
|
62
63
|
else {
|
|
63
64
|
const results = await Promise.all(issueIds.map(async (id) => {
|
|
64
65
|
const issue = await issuesService.getIssueById(id);
|
|
65
|
-
return downloadLinearUploads(issue,
|
|
66
|
+
return downloadLinearUploads(issue, fileService);
|
|
66
67
|
}));
|
|
67
68
|
outputSuccess(results);
|
|
68
69
|
}
|
package/dist/commands/refs.js
CHANGED
|
@@ -4,6 +4,7 @@ import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
|
|
|
4
4
|
import { wrapIssueReferencesAsLinks, } from "../utils/issue-reference-wrapper.js";
|
|
5
5
|
import { createLinearService } from "../utils/linear-service.js";
|
|
6
6
|
import { handleAsyncCommand } from "../utils/output.js";
|
|
7
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
7
8
|
import { validateReferences } from "../utils/validate-references.js";
|
|
8
9
|
import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
|
|
9
10
|
const VALID_TARGETS = new Set(["markdown", "slack"]);
|
|
@@ -58,15 +59,15 @@ async function handleWrap(options, command) {
|
|
|
58
59
|
const text = typeof options.file === "string" && options.file.length > 0
|
|
59
60
|
? readFileSync(options.file, "utf8")
|
|
60
61
|
: await readAllStdin();
|
|
61
|
-
const rootOpts = command
|
|
62
|
+
const rootOpts = getRootOpts(command);
|
|
62
63
|
const deps = {
|
|
63
64
|
async resolveValidIdentifiers(ids) {
|
|
64
|
-
const linearService = createLinearService(rootOpts);
|
|
65
|
+
const linearService = await createLinearService(rootOpts);
|
|
65
66
|
const map = await validateReferences(ids, linearService);
|
|
66
67
|
return new Set(map.keys());
|
|
67
68
|
},
|
|
68
69
|
async resolveUrlKey() {
|
|
69
|
-
const graphQLService = createGraphQLService(rootOpts);
|
|
70
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
70
71
|
return getWorkspaceUrlKey(graphQLService);
|
|
71
72
|
},
|
|
72
73
|
};
|