linmux 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.
- package/LICENSE +21 -0
- package/README.md +240 -0
- package/bin/run.js +4 -0
- package/dist/commands/comment/create.js +94 -0
- package/dist/commands/comment/delete.js +74 -0
- package/dist/commands/comment/list.js +84 -0
- package/dist/commands/comment/update.js +80 -0
- package/dist/commands/cycle/current.js +78 -0
- package/dist/commands/cycle/list.js +84 -0
- package/dist/commands/cycle/move.js +91 -0
- package/dist/commands/describe.js +65 -0
- package/dist/commands/graphql/index.js +92 -0
- package/dist/commands/install-skill.js +54 -0
- package/dist/commands/issue/archive.js +75 -0
- package/dist/commands/issue/create.js +115 -0
- package/dist/commands/issue/get.js +84 -0
- package/dist/commands/issue/list.js +93 -0
- package/dist/commands/issue/purge.js +81 -0
- package/dist/commands/issue/search.js +109 -0
- package/dist/commands/issue/transition.js +91 -0
- package/dist/commands/issue/trash.js +75 -0
- package/dist/commands/issue/update.js +126 -0
- package/dist/commands/label/create.js +91 -0
- package/dist/commands/label/list.js +76 -0
- package/dist/commands/list-tools.js +47 -0
- package/dist/commands/me.js +71 -0
- package/dist/commands/project/create.js +101 -0
- package/dist/commands/project/get.js +83 -0
- package/dist/commands/project/list.js +75 -0
- package/dist/commands/project/update-status.js +99 -0
- package/dist/commands/project/update.js +99 -0
- package/dist/commands/raw/batch.js +85 -0
- package/dist/commands/raw/index.js +72 -0
- package/dist/commands/schema.js +69 -0
- package/dist/commands/state/list.js +77 -0
- package/dist/commands/team/get.js +73 -0
- package/dist/commands/team/list.js +73 -0
- package/dist/commands/whoami.js +71 -0
- package/dist/commands/workspace/add.js +97 -0
- package/dist/commands/workspace/list.js +47 -0
- package/dist/commands/workspace/remove.js +63 -0
- package/dist/commands/workspace/replace-token.js +89 -0
- package/dist/commands/workspace/use.js +54 -0
- package/dist/core/client/factory.js +28 -0
- package/dist/core/client/index.js +2 -0
- package/dist/core/config/index.js +4 -0
- package/dist/core/config/paths.js +30 -0
- package/dist/core/config/schema.js +36 -0
- package/dist/core/config/store.js +149 -0
- package/dist/core/errors/error.js +142 -0
- package/dist/core/errors/exit-codes.js +70 -0
- package/dist/core/output/envelope.js +53 -0
- package/dist/core/output/format.js +42 -0
- package/dist/core/output/index.js +3 -0
- package/dist/core/pagination/flags.js +29 -0
- package/dist/core/pagination/index.js +2 -0
- package/dist/core/projection/presets.js +116 -0
- package/dist/core/projection/project.js +282 -0
- package/dist/core/redact/redact.js +45 -0
- package/dist/core/resolvers/cycle.js +60 -0
- package/dist/core/resolvers/index.js +7 -0
- package/dist/core/resolvers/label.js +54 -0
- package/dist/core/resolvers/project-status.js +42 -0
- package/dist/core/resolvers/project.js +43 -0
- package/dist/core/resolvers/state.js +46 -0
- package/dist/core/resolvers/team.js +50 -0
- package/dist/core/transport/fetch-interceptor.js +109 -0
- package/dist/core/transport/index.js +3 -0
- package/dist/core/transport/rate-limit.js +167 -0
- package/dist/core/workspace/resolver.js +70 -0
- package/dist/core/workspace/write-guard.js +43 -0
- package/dist/generated/graphql.js +89428 -0
- package/dist/generated/operations.js +3013 -0
- package/dist/lib/comment-create-runtime.js +96 -0
- package/dist/lib/comment-delete-runtime.js +46 -0
- package/dist/lib/comment-list-runtime.js +182 -0
- package/dist/lib/comment-update-runtime.js +93 -0
- package/dist/lib/cycle-current-runtime.js +90 -0
- package/dist/lib/cycle-list-runtime.js +151 -0
- package/dist/lib/cycle-move-runtime.js +142 -0
- package/dist/lib/describe-runtime.js +180 -0
- package/dist/lib/filter-heuristics.js +59 -0
- package/dist/lib/graphql-runtime.js +202 -0
- package/dist/lib/include-fragments.js +73 -0
- package/dist/lib/install-skill-runtime.js +228 -0
- package/dist/lib/introspection-registry.js +488 -0
- package/dist/lib/issue-archive-runtime.js +89 -0
- package/dist/lib/issue-create-runtime.js +175 -0
- package/dist/lib/issue-get-runtime.js +153 -0
- package/dist/lib/issue-list-runtime.js +164 -0
- package/dist/lib/issue-purge-runtime.js +89 -0
- package/dist/lib/issue-search-runtime.js +114 -0
- package/dist/lib/issue-transition-runtime.js +131 -0
- package/dist/lib/issue-trash-runtime.js +84 -0
- package/dist/lib/issue-update-runtime.js +164 -0
- package/dist/lib/label-create-runtime.js +113 -0
- package/dist/lib/label-list-runtime.js +97 -0
- package/dist/lib/levenshtein.js +42 -0
- package/dist/lib/list-tools-runtime.js +38 -0
- package/dist/lib/me-runtime.js +55 -0
- package/dist/lib/project-create-runtime.js +103 -0
- package/dist/lib/project-get-runtime.js +134 -0
- package/dist/lib/project-list-runtime.js +84 -0
- package/dist/lib/project-update-runtime.js +110 -0
- package/dist/lib/project-update-status-runtime.js +91 -0
- package/dist/lib/raw-batch-runtime.js +229 -0
- package/dist/lib/raw-runtime.js +171 -0
- package/dist/lib/schema-loader.js +41 -0
- package/dist/lib/schema-runtime.js +65 -0
- package/dist/lib/state-list-runtime.js +93 -0
- package/dist/lib/team-get-runtime.js +55 -0
- package/dist/lib/team-list-runtime.js +52 -0
- package/dist/lib/workspace-runtime.js +112 -0
- package/dist/operations/_registry.zod.js +5337 -0
- package/oclif.manifest.json +3631 -0
- package/package.json +99 -0
- package/schema.graphql +30772 -0
- package/skills/linmux/SKILL.md +186 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { LinearAgentError } from "../../core/errors/error.js";
|
|
2
|
+
import { BASE_FLAGS, runCommand } from "../../lib/workspace-runtime.js";
|
|
3
|
+
import { updateConfig } from "../../core/config/store.js";
|
|
4
|
+
import "../../core/config/index.js";
|
|
5
|
+
import { Args, Command } from "@oclif/core";
|
|
6
|
+
//#region src/commands/workspace/remove.ts
|
|
7
|
+
async function runWorkspaceRemove(args) {
|
|
8
|
+
const runArgs = {
|
|
9
|
+
commandPath: "workspace remove",
|
|
10
|
+
pretty: args.pretty,
|
|
11
|
+
handler: async (_retryOpts) => {
|
|
12
|
+
let nextActive = null;
|
|
13
|
+
updateConfig((current) => {
|
|
14
|
+
if (!Object.hasOwn(current.workspaces, args.name)) throw LinearAgentError.workspace.notFound(args.name);
|
|
15
|
+
const remaining = {};
|
|
16
|
+
for (const [k, v] of Object.entries(current.workspaces)) if (k !== args.name) remaining[k] = v;
|
|
17
|
+
if (current.active === args.name) nextActive = Object.keys(remaining).sort()[0] ?? null;
|
|
18
|
+
else nextActive = current.active;
|
|
19
|
+
return {
|
|
20
|
+
active: nextActive,
|
|
21
|
+
workspaces: remaining
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
data: {
|
|
26
|
+
removed: args.name,
|
|
27
|
+
active: nextActive
|
|
28
|
+
},
|
|
29
|
+
meta: {}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
if (args.noMeta !== void 0) runArgs.noMeta = args.noMeta;
|
|
34
|
+
if (args.quiet !== void 0) runArgs.quiet = args.quiet;
|
|
35
|
+
if (args.retry !== void 0) runArgs.retry = args.retry;
|
|
36
|
+
return runCommand(runArgs);
|
|
37
|
+
}
|
|
38
|
+
var WorkspaceRemove = class WorkspaceRemove extends Command {
|
|
39
|
+
static description = "Remove a workspace from local config";
|
|
40
|
+
static enableJsonFlag = true;
|
|
41
|
+
static args = { name: Args.string({
|
|
42
|
+
required: true,
|
|
43
|
+
description: "Name of the workspace to remove"
|
|
44
|
+
}) };
|
|
45
|
+
static flags = { ...BASE_FLAGS };
|
|
46
|
+
async run() {
|
|
47
|
+
const { args, flags } = await this.parse(WorkspaceRemove);
|
|
48
|
+
const callArgs = {
|
|
49
|
+
name: args.name,
|
|
50
|
+
pretty: flags.pretty
|
|
51
|
+
};
|
|
52
|
+
if (flags.quiet !== void 0) callArgs.quiet = flags.quiet;
|
|
53
|
+
if (flags.noMeta !== void 0) callArgs.noMeta = flags.noMeta;
|
|
54
|
+
if (flags.retry !== void 0) callArgs.retry = flags.retry;
|
|
55
|
+
const out = await runWorkspaceRemove(callArgs);
|
|
56
|
+
if (!flags.json) process.stdout.write(out.stdout);
|
|
57
|
+
if (out.stderr) process.stderr.write(out.stderr);
|
|
58
|
+
if (out.exitCode !== 0) process.exitCode = out.exitCode;
|
|
59
|
+
return JSON.parse(out.stdout);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
//#endregion
|
|
63
|
+
export { WorkspaceRemove as default, runWorkspaceRemove };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { LinearAgentError } from "../../core/errors/error.js";
|
|
2
|
+
import { withFetchInterception } from "../../core/transport/fetch-interceptor.js";
|
|
3
|
+
import { withRateLimitRetry } from "../../core/transport/rate-limit.js";
|
|
4
|
+
import "../../core/transport/index.js";
|
|
5
|
+
import { BASE_FLAGS, runCommand } from "../../lib/workspace-runtime.js";
|
|
6
|
+
import { createLinearClient } from "../../core/client/factory.js";
|
|
7
|
+
import "../../core/client/index.js";
|
|
8
|
+
import { loadConfig, saveConfig } from "../../core/config/store.js";
|
|
9
|
+
import "../../core/config/index.js";
|
|
10
|
+
import { Args, Command, Flags } from "@oclif/core";
|
|
11
|
+
//#region src/commands/workspace/replace-token.ts
|
|
12
|
+
async function runWorkspaceReplaceToken(args) {
|
|
13
|
+
const runArgs = {
|
|
14
|
+
commandPath: "workspace replace-token",
|
|
15
|
+
pretty: args.pretty,
|
|
16
|
+
handler: async (retryOpts) => {
|
|
17
|
+
const config = loadConfig();
|
|
18
|
+
const entry = config.workspaces[args.name];
|
|
19
|
+
if (!entry) throw LinearAgentError.workspace.notFound(args.name);
|
|
20
|
+
const expectedOrgId = entry.organizationId;
|
|
21
|
+
const client = createLinearClient({
|
|
22
|
+
name: null,
|
|
23
|
+
token: args.token,
|
|
24
|
+
organizationId: null,
|
|
25
|
+
source: "api-key-env"
|
|
26
|
+
});
|
|
27
|
+
const effectiveRetryOpts = args.retryOptsOverride ?? retryOpts;
|
|
28
|
+
const actualOrgId = await withFetchInterception(async () => {
|
|
29
|
+
const viewer = await withRateLimitRetry(() => client.viewer, effectiveRetryOpts);
|
|
30
|
+
return (await withRateLimitRetry(() => viewer.organization, effectiveRetryOpts)).id;
|
|
31
|
+
});
|
|
32
|
+
if (actualOrgId !== expectedOrgId) throw LinearAgentError.workspace.tokenMismatch(expectedOrgId, actualOrgId);
|
|
33
|
+
saveConfig({
|
|
34
|
+
...config,
|
|
35
|
+
workspaces: {
|
|
36
|
+
...config.workspaces,
|
|
37
|
+
[args.name]: {
|
|
38
|
+
...entry,
|
|
39
|
+
token: args.token
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
data: {
|
|
45
|
+
name: args.name,
|
|
46
|
+
organizationId: expectedOrgId
|
|
47
|
+
},
|
|
48
|
+
meta: { workspace: args.name }
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
if (args.noMeta !== void 0) runArgs.noMeta = args.noMeta;
|
|
53
|
+
if (args.quiet !== void 0) runArgs.quiet = args.quiet;
|
|
54
|
+
if (args.retry !== void 0) runArgs.retry = args.retry;
|
|
55
|
+
return runCommand(runArgs);
|
|
56
|
+
}
|
|
57
|
+
var WorkspaceReplaceToken = class WorkspaceReplaceToken extends Command {
|
|
58
|
+
static description = "Rotate the API token for a registered workspace";
|
|
59
|
+
static enableJsonFlag = true;
|
|
60
|
+
static args = { name: Args.string({
|
|
61
|
+
required: true,
|
|
62
|
+
description: "Name of the workspace to rotate"
|
|
63
|
+
}) };
|
|
64
|
+
static flags = {
|
|
65
|
+
token: Flags.string({
|
|
66
|
+
required: true,
|
|
67
|
+
description: "New Linear personal API key (lin_api_*)"
|
|
68
|
+
}),
|
|
69
|
+
...BASE_FLAGS
|
|
70
|
+
};
|
|
71
|
+
async run() {
|
|
72
|
+
const { args, flags } = await this.parse(WorkspaceReplaceToken);
|
|
73
|
+
const callArgs = {
|
|
74
|
+
name: args.name,
|
|
75
|
+
token: flags.token,
|
|
76
|
+
pretty: flags.pretty
|
|
77
|
+
};
|
|
78
|
+
if (flags.quiet !== void 0) callArgs.quiet = flags.quiet;
|
|
79
|
+
if (flags.noMeta !== void 0) callArgs.noMeta = flags.noMeta;
|
|
80
|
+
if (flags.retry !== void 0) callArgs.retry = flags.retry;
|
|
81
|
+
const out = await runWorkspaceReplaceToken(callArgs);
|
|
82
|
+
if (!flags.json) process.stdout.write(out.stdout);
|
|
83
|
+
if (out.stderr) process.stderr.write(out.stderr);
|
|
84
|
+
if (out.exitCode !== 0) process.exitCode = out.exitCode;
|
|
85
|
+
return JSON.parse(out.stdout);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
//#endregion
|
|
89
|
+
export { WorkspaceReplaceToken as default, runWorkspaceReplaceToken };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { LinearAgentError } from "../../core/errors/error.js";
|
|
2
|
+
import { BASE_FLAGS, runCommand } from "../../lib/workspace-runtime.js";
|
|
3
|
+
import { updateConfig } from "../../core/config/store.js";
|
|
4
|
+
import "../../core/config/index.js";
|
|
5
|
+
import { Args, Command } from "@oclif/core";
|
|
6
|
+
//#region src/commands/workspace/use.ts
|
|
7
|
+
async function runWorkspaceUse(args) {
|
|
8
|
+
const runArgs = {
|
|
9
|
+
commandPath: "workspace use",
|
|
10
|
+
pretty: args.pretty,
|
|
11
|
+
handler: async (_retryOpts) => {
|
|
12
|
+
return {
|
|
13
|
+
data: { active: updateConfig((current) => {
|
|
14
|
+
if (!Object.hasOwn(current.workspaces, args.name)) throw LinearAgentError.workspace.notFound(args.name);
|
|
15
|
+
return {
|
|
16
|
+
...current,
|
|
17
|
+
active: args.name
|
|
18
|
+
};
|
|
19
|
+
}).active },
|
|
20
|
+
meta: { workspace: args.name }
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
if (args.noMeta !== void 0) runArgs.noMeta = args.noMeta;
|
|
25
|
+
if (args.quiet !== void 0) runArgs.quiet = args.quiet;
|
|
26
|
+
if (args.retry !== void 0) runArgs.retry = args.retry;
|
|
27
|
+
return runCommand(runArgs);
|
|
28
|
+
}
|
|
29
|
+
var WorkspaceUse = class WorkspaceUse extends Command {
|
|
30
|
+
static description = "Set the active default workspace";
|
|
31
|
+
static enableJsonFlag = true;
|
|
32
|
+
static args = { name: Args.string({
|
|
33
|
+
required: true,
|
|
34
|
+
description: "Name of the workspace to make active"
|
|
35
|
+
}) };
|
|
36
|
+
static flags = { ...BASE_FLAGS };
|
|
37
|
+
async run() {
|
|
38
|
+
const { args, flags } = await this.parse(WorkspaceUse);
|
|
39
|
+
const callArgs = {
|
|
40
|
+
name: args.name,
|
|
41
|
+
pretty: flags.pretty
|
|
42
|
+
};
|
|
43
|
+
if (flags.quiet !== void 0) callArgs.quiet = flags.quiet;
|
|
44
|
+
if (flags.noMeta !== void 0) callArgs.noMeta = flags.noMeta;
|
|
45
|
+
if (flags.retry !== void 0) callArgs.retry = flags.retry;
|
|
46
|
+
const out = await runWorkspaceUse(callArgs);
|
|
47
|
+
if (!flags.json) process.stdout.write(out.stdout);
|
|
48
|
+
if (out.stderr) process.stderr.write(out.stderr);
|
|
49
|
+
if (out.exitCode !== 0) process.exitCode = out.exitCode;
|
|
50
|
+
return JSON.parse(out.stdout);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { WorkspaceUse as default, runWorkspaceUse };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { LinearClient } from "@linear/sdk";
|
|
2
|
+
//#region src/core/client/factory.ts
|
|
3
|
+
/**
|
|
4
|
+
* KRN-05: a fresh `LinearClient` per CLI invocation.
|
|
5
|
+
*
|
|
6
|
+
* This factory is deliberately uncached. Every call constructs a new
|
|
7
|
+
* `LinearClient` from the resolved token. The token-pinning bug class
|
|
8
|
+
* (PITFALLS § Pitfall 2 — "stale token reused after workspace switch") is
|
|
9
|
+
* impossible by construction.
|
|
10
|
+
*
|
|
11
|
+
* Why no caching:
|
|
12
|
+
* - Each CLI invocation is a fresh process. There is no "warm" client to
|
|
13
|
+
* reuse across invocations.
|
|
14
|
+
* - Within a single invocation, workspace is resolved exactly once at
|
|
15
|
+
* startup; we never switch mid-process.
|
|
16
|
+
* - Even if we DID cache, the cache key would have to include the resolved
|
|
17
|
+
* token — at which point the cache buys us nothing on a single-workspace
|
|
18
|
+
* invocation and is actively dangerous if a future refactor tries to
|
|
19
|
+
* share clients across workspaces.
|
|
20
|
+
*
|
|
21
|
+
* The SDK handles Linear's no-`Bearer ` Authorization header convention
|
|
22
|
+
* internally; the caller passes `apiKey` and never touches the header.
|
|
23
|
+
*/
|
|
24
|
+
function createLinearClient(resolved) {
|
|
25
|
+
return new LinearClient({ apiKey: resolved.token });
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { createLinearClient };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
//#region src/core/config/paths.ts
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the directory the config file lives in.
|
|
6
|
+
*
|
|
7
|
+
* Order:
|
|
8
|
+
* 1. `$XDG_CONFIG_HOME/linear-agent` if the env var is set and non-empty
|
|
9
|
+
* 2. `~/.config/linear-agent` otherwise
|
|
10
|
+
*
|
|
11
|
+
* NOTE: the on-disk directory name is intentionally kept as `linear-agent`
|
|
12
|
+
* (the CLI's former name) so existing registered workspaces survive the
|
|
13
|
+
* rename to `linmux` without a migration. Do not change this string without a
|
|
14
|
+
* read-side fallback that copies the legacy config — see CHANGELOG 0.1.0.
|
|
15
|
+
*
|
|
16
|
+
* Cross-platform note: this honors XDG on Linux/macOS (and on Windows when
|
|
17
|
+
* a shell sets the env explicitly). Native Windows path defaults
|
|
18
|
+
* (`%APPDATA%\linear-agent`) are out of scope for v1 — see PROJECT.md
|
|
19
|
+
* runtime parity matrix; the file mode story doesn't apply on NTFS anyway.
|
|
20
|
+
*/
|
|
21
|
+
function configDir() {
|
|
22
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
23
|
+
return join(xdg && xdg.length > 0 ? xdg : join(homedir(), ".config"), "linear-agent");
|
|
24
|
+
}
|
|
25
|
+
/** Full path to the JSON config file (`<configDir>/config.json`). */
|
|
26
|
+
function configPath() {
|
|
27
|
+
return join(configDir(), "config.json");
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
export { configDir, configPath };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/core/config/schema.ts
|
|
3
|
+
/**
|
|
4
|
+
* Schema for one entry in the workspaces map.
|
|
5
|
+
*
|
|
6
|
+
* `token` holds a Linear Personal API key. The redactor scrubs the token
|
|
7
|
+
* value out of any rendered envelope; the schema treats it as an
|
|
8
|
+
* opaque non-empty string so we never reveal/decode it client-side.
|
|
9
|
+
*
|
|
10
|
+
* `organizationId` is captured at `workspace add` time via the SDK's
|
|
11
|
+
* `viewer.organization.id` query (PLAN-04 wires this in). The schema
|
|
12
|
+
* reserves the column here.
|
|
13
|
+
*/
|
|
14
|
+
const WorkspaceEntrySchema = z.object({
|
|
15
|
+
name: z.string().min(1),
|
|
16
|
+
token: z.string().min(1),
|
|
17
|
+
organizationId: z.string().min(1),
|
|
18
|
+
createdAt: z.string().datetime(),
|
|
19
|
+
lastUsedAt: z.string().datetime().optional()
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* Top-level config shape: `{ active, workspaces }`.
|
|
23
|
+
*
|
|
24
|
+
* Invariant: when `active` is non-null, it MUST exist as a key in
|
|
25
|
+
* `workspaces` (refine below). This catches hand-edited configs that
|
|
26
|
+
* dangle the active pointer at a removed workspace.
|
|
27
|
+
*/
|
|
28
|
+
const ConfigSchema = z.object({
|
|
29
|
+
active: z.string().min(1).nullable(),
|
|
30
|
+
workspaces: z.record(z.string(), WorkspaceEntrySchema)
|
|
31
|
+
}).refine((c) => c.active === null || Object.hasOwn(c.workspaces, c.active), {
|
|
32
|
+
message: "active references an unregistered workspace",
|
|
33
|
+
path: ["active"]
|
|
34
|
+
});
|
|
35
|
+
//#endregion
|
|
36
|
+
export { ConfigSchema, WorkspaceEntrySchema };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { LinearAgentError } from "../errors/error.js";
|
|
2
|
+
import { configPath } from "./paths.js";
|
|
3
|
+
import { ConfigSchema } from "./schema.js";
|
|
4
|
+
import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
//#region src/core/config/store.ts
|
|
8
|
+
/**
|
|
9
|
+
* ConfigStore — atomic, 0600-aware reader/writer for the workspace registry.
|
|
10
|
+
*
|
|
11
|
+
* Design rationale (PITFALLS § Pitfalls 3,4 + CONTEXT § Config Storage):
|
|
12
|
+
*
|
|
13
|
+
* We do NOT use `conf@^14` for this file. `conf` is excellent for general
|
|
14
|
+
* user-config storage but does not enforce a strict 0600 mode on read,
|
|
15
|
+
* which is precisely the property we need for token-bearing files. Wrapping
|
|
16
|
+
* `conf` to add the mode check would split logic across two libraries; a
|
|
17
|
+
* ~80 LOC bespoke writer is simpler to audit and easier to keep correct.
|
|
18
|
+
*
|
|
19
|
+
* The store enforces three contract guarantees:
|
|
20
|
+
* 1. The file mode on disk is exactly 0600 after every write.
|
|
21
|
+
* 2. Reading a file whose mode is broader than 0600 fails closed with
|
|
22
|
+
* `CONFIG_PERMISSIONS_TOO_BROAD` (exit 11) BEFORE returning data.
|
|
23
|
+
* 3. Writes are atomic from a reader's perspective: a sibling temp file
|
|
24
|
+
* (created with mode 0600 + fsync'd) is `rename`d into place. Readers
|
|
25
|
+
* never see a partial file.
|
|
26
|
+
*
|
|
27
|
+
* Cross-platform note: POSIX modes don't apply on NTFS, so the
|
|
28
|
+
* permission-check is a no-op on Windows. v1 documents this as a known
|
|
29
|
+
* limitation; multi-user Windows boxes are outside the v1 threat model.
|
|
30
|
+
*/
|
|
31
|
+
const REQUIRED_DIR_MODE = 448;
|
|
32
|
+
const IS_WINDOWS = process.platform === "win32";
|
|
33
|
+
/**
|
|
34
|
+
* Read the config file. Returns the empty config (`{ active: null, workspaces: {} }`)
|
|
35
|
+
* if the file is missing — the very first `workspace add` writes it.
|
|
36
|
+
*
|
|
37
|
+
* Throws:
|
|
38
|
+
* - `CONFIG_PERMISSIONS_TOO_BROAD` (exit 11) if the file mode is broader
|
|
39
|
+
* than 0600 on a POSIX platform. Recovery: `chmod 600 <path>`.
|
|
40
|
+
* - `VALIDATION_FAILED` (exit 12) on malformed JSON or schema mismatch.
|
|
41
|
+
*/
|
|
42
|
+
function loadConfig(opts = {}) {
|
|
43
|
+
const target = opts.path ?? configPath();
|
|
44
|
+
let stat;
|
|
45
|
+
try {
|
|
46
|
+
stat = statSync(target);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
if (isErrnoException(e) && e.code === "ENOENT") return cloneEmpty();
|
|
49
|
+
throw LinearAgentError.generic(`failed to stat config file: ${e?.message ?? String(e)}`);
|
|
50
|
+
}
|
|
51
|
+
if (!IS_WINDOWS) {
|
|
52
|
+
const fileMode = stat.mode & 511;
|
|
53
|
+
if (fileMode !== 384) throw LinearAgentError.auth.configPermissionsTooBroad(target, octal(fileMode));
|
|
54
|
+
}
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = readFileSync(target, "utf8");
|
|
58
|
+
} catch (e) {
|
|
59
|
+
throw LinearAgentError.generic(`failed to read config file: ${e?.message ?? String(e)}`);
|
|
60
|
+
}
|
|
61
|
+
let parsedJson;
|
|
62
|
+
try {
|
|
63
|
+
parsedJson = JSON.parse(raw);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
throw LinearAgentError.validation.failed("config file is not valid JSON", {
|
|
66
|
+
stage: "json-parse",
|
|
67
|
+
path: target,
|
|
68
|
+
parseError: e?.message ?? String(e)
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const result = ConfigSchema.safeParse(parsedJson);
|
|
72
|
+
if (!result.success) throw LinearAgentError.validation.failed("config file failed schema validation", {
|
|
73
|
+
stage: "schema",
|
|
74
|
+
path: target,
|
|
75
|
+
issues: result.error.issues
|
|
76
|
+
});
|
|
77
|
+
return result.data;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Write the config to disk atomically with mode 0600.
|
|
81
|
+
*
|
|
82
|
+
* The write protocol:
|
|
83
|
+
* 1. Ensure parent directory exists with mode 0700 (recursive).
|
|
84
|
+
* 2. Open a sibling temp file with `O_CREAT | O_WRONLY` and mode 0600.
|
|
85
|
+
* 3. Write the serialized config + newline; `fsync`; `close`.
|
|
86
|
+
* 4. `chmod 0600` defensively (umask may have masked the open() mode).
|
|
87
|
+
* 5. `rename` the temp file over the target — atomic on POSIX.
|
|
88
|
+
* 6. On any error mid-flight, attempt to unlink the temp file.
|
|
89
|
+
*/
|
|
90
|
+
function saveConfig(config, opts = {}) {
|
|
91
|
+
const validated = ConfigSchema.parse(config);
|
|
92
|
+
const target = opts.path ?? configPath();
|
|
93
|
+
const parent = dirname(target);
|
|
94
|
+
try {
|
|
95
|
+
mkdirSync(parent, {
|
|
96
|
+
recursive: true,
|
|
97
|
+
mode: REQUIRED_DIR_MODE
|
|
98
|
+
});
|
|
99
|
+
} catch (e) {
|
|
100
|
+
throw LinearAgentError.generic(`failed to create config directory: ${e?.message ?? String(e)}`);
|
|
101
|
+
}
|
|
102
|
+
const tempPath = `${target}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
|
|
103
|
+
let fd;
|
|
104
|
+
try {
|
|
105
|
+
fd = openSync(tempPath, "wx", 384);
|
|
106
|
+
const payload = `${JSON.stringify(validated, null, 2)}\n`;
|
|
107
|
+
writeSync(fd, payload);
|
|
108
|
+
fsyncSync(fd);
|
|
109
|
+
closeSync(fd);
|
|
110
|
+
fd = void 0;
|
|
111
|
+
if (!IS_WINDOWS) chmodSync(tempPath, 384);
|
|
112
|
+
renameSync(tempPath, target);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
if (fd !== void 0) try {
|
|
115
|
+
closeSync(fd);
|
|
116
|
+
} catch {}
|
|
117
|
+
try {
|
|
118
|
+
unlinkSync(tempPath);
|
|
119
|
+
} catch {}
|
|
120
|
+
if (e instanceof LinearAgentError) throw e;
|
|
121
|
+
throw LinearAgentError.generic(`failed to write config file: ${e?.message ?? String(e)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Convenience: load → mutate → save in one call. Used by
|
|
126
|
+
* `workspace add/use/remove/replace-token` in PLAN-04.
|
|
127
|
+
*
|
|
128
|
+
* Returns the saved config (post-mutator) so callers can inspect the result
|
|
129
|
+
* without an extra `loadConfig` round-trip.
|
|
130
|
+
*/
|
|
131
|
+
function updateConfig(mutator, opts = {}) {
|
|
132
|
+
const next = mutator(loadConfig({ path: opts.path }));
|
|
133
|
+
saveConfig(next, opts);
|
|
134
|
+
return next;
|
|
135
|
+
}
|
|
136
|
+
function cloneEmpty() {
|
|
137
|
+
return {
|
|
138
|
+
active: null,
|
|
139
|
+
workspaces: {}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function octal(mode) {
|
|
143
|
+
return `0${mode.toString(8).padStart(3, "0")}`;
|
|
144
|
+
}
|
|
145
|
+
function isErrnoException(e) {
|
|
146
|
+
return e instanceof Error && typeof e.code === "string";
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
export { loadConfig, saveConfig, updateConfig };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
//#region src/core/errors/error.ts
|
|
2
|
+
/**
|
|
3
|
+
* Substring patterns that must never appear in a LinearAgentError's
|
|
4
|
+
* `message` field. The redactor (`src/core/redact/redact.ts`) is the
|
|
5
|
+
* primary scrubber — this constructor-level guard is defense in depth
|
|
6
|
+
* for callers that hand-craft messages.
|
|
7
|
+
*
|
|
8
|
+
* Linear PATs are formatted `lin_api_<base64-ish>` (and `lin_oauth_<...>`
|
|
9
|
+
* for OAuth tokens). We forbid the literal prefixes anywhere in the
|
|
10
|
+
* message string, not just at the start, because `Error("Authorization:
|
|
11
|
+
* lin_api_...")` is the exact pattern callers tend to construct.
|
|
12
|
+
*/
|
|
13
|
+
const FORBIDDEN_TOKEN_PREFIXES = ["lin_api_", "lin_oauth_"];
|
|
14
|
+
/** Codes whose default `transient` value is `true`. */
|
|
15
|
+
const TRANSIENT_BY_DEFAULT = new Set(["RATELIMITED", "NETWORK_ERROR"]);
|
|
16
|
+
/**
|
|
17
|
+
* The single error class for the kernel. One base class with the code
|
|
18
|
+
* enum, no per-code subclasses (over-engineering — see CONTEXT § specifics).
|
|
19
|
+
*
|
|
20
|
+
* Construction-time guarantees:
|
|
21
|
+
* - `code` is a member of the frozen taxonomy (TS-enforced).
|
|
22
|
+
* - `message` does NOT contain a literal `lin_api_` or `lin_oauth_`
|
|
23
|
+
* substring. This is defense in depth on top of the redactor — if a
|
|
24
|
+
* caller hand-crafts a message that bakes in a token, the constructor
|
|
25
|
+
* throws a *plain* `Error` (not a `LinearAgentError`) so the failure
|
|
26
|
+
* surfaces as a developer bug rather than a user-facing error.
|
|
27
|
+
* - `transient` defaults to `true` for `RATELIMITED` and `NETWORK_ERROR`,
|
|
28
|
+
* `false` for everything else. Callers may override.
|
|
29
|
+
*/
|
|
30
|
+
var LinearAgentError = class LinearAgentError extends Error {
|
|
31
|
+
name = "LinearAgentError";
|
|
32
|
+
code;
|
|
33
|
+
transient;
|
|
34
|
+
retryAfterMs;
|
|
35
|
+
details;
|
|
36
|
+
constructor(init) {
|
|
37
|
+
super(init.message);
|
|
38
|
+
for (const prefix of FORBIDDEN_TOKEN_PREFIXES) if (init.message.includes(prefix)) throw new Error(`token-shaped substring forbidden in error message (matched ${prefix}*); use the redactor or strip the token before constructing the error`);
|
|
39
|
+
this.code = init.code;
|
|
40
|
+
this.transient = init.transient ?? TRANSIENT_BY_DEFAULT.has(init.code);
|
|
41
|
+
if (init.retryAfterMs !== void 0) this.retryAfterMs = init.retryAfterMs;
|
|
42
|
+
if (init.details !== void 0) this.details = init.details;
|
|
43
|
+
}
|
|
44
|
+
static workspace = {
|
|
45
|
+
notResolved: (detail) => new LinearAgentError({
|
|
46
|
+
code: "WORKSPACE_NOT_RESOLVED",
|
|
47
|
+
message: detail ?? "no workspace could be resolved for this invocation"
|
|
48
|
+
}),
|
|
49
|
+
notFound: (name) => new LinearAgentError({
|
|
50
|
+
code: "WORKSPACE_NOT_FOUND",
|
|
51
|
+
message: `workspace not found: ${name}`,
|
|
52
|
+
details: { workspace: name }
|
|
53
|
+
}),
|
|
54
|
+
requiredForWrite: () => new LinearAgentError({
|
|
55
|
+
code: "WORKSPACE_REQUIRED_FOR_WRITE",
|
|
56
|
+
message: "write commands require an explicit --workspace flag, LINEAR_WORKSPACE env, or --allow-active-workspace-write opt-in"
|
|
57
|
+
}),
|
|
58
|
+
tokenMismatch: (expected, got) => new LinearAgentError({
|
|
59
|
+
code: "WORKSPACE_TOKEN_MISMATCH",
|
|
60
|
+
message: "token does not match the organizationId stored for this workspace",
|
|
61
|
+
details: {
|
|
62
|
+
expectedOrganizationId: expected,
|
|
63
|
+
gotOrganizationId: got
|
|
64
|
+
}
|
|
65
|
+
}),
|
|
66
|
+
alreadyExists: (name) => new LinearAgentError({
|
|
67
|
+
code: "WORKSPACE_ALREADY_EXISTS",
|
|
68
|
+
message: `workspace already registered: ${name} (use replace-token to rotate)`,
|
|
69
|
+
details: { workspace: name }
|
|
70
|
+
})
|
|
71
|
+
};
|
|
72
|
+
static auth = {
|
|
73
|
+
invalid: (detail) => new LinearAgentError({
|
|
74
|
+
code: "AUTH_INVALID",
|
|
75
|
+
message: detail ?? "authentication token is invalid or expired"
|
|
76
|
+
}),
|
|
77
|
+
configPermissionsTooBroad: (path, mode) => new LinearAgentError({
|
|
78
|
+
code: "CONFIG_PERMISSIONS_TOO_BROAD",
|
|
79
|
+
message: `config file mode is broader than 0600 (${mode}); run \`chmod 600 ${path}\``,
|
|
80
|
+
details: {
|
|
81
|
+
path,
|
|
82
|
+
mode
|
|
83
|
+
}
|
|
84
|
+
}),
|
|
85
|
+
configNotFound: (path) => new LinearAgentError({
|
|
86
|
+
code: "CONFIG_NOT_FOUND",
|
|
87
|
+
message: `config file not found: ${path}`,
|
|
88
|
+
details: { path }
|
|
89
|
+
})
|
|
90
|
+
};
|
|
91
|
+
static validation = {
|
|
92
|
+
failed: (detail, details) => new LinearAgentError({
|
|
93
|
+
code: "VALIDATION_FAILED",
|
|
94
|
+
message: detail,
|
|
95
|
+
...details !== void 0 ? { details } : {}
|
|
96
|
+
}),
|
|
97
|
+
invalidField: (field, allowed) => new LinearAgentError({
|
|
98
|
+
code: "INVALID_FIELD",
|
|
99
|
+
message: `unknown field: ${field}`,
|
|
100
|
+
details: allowed !== void 0 ? {
|
|
101
|
+
field,
|
|
102
|
+
allowed: [...allowed]
|
|
103
|
+
} : { field }
|
|
104
|
+
})
|
|
105
|
+
};
|
|
106
|
+
static linear = { apiError: (init) => new LinearAgentError({
|
|
107
|
+
code: "LINEAR_API_ERROR",
|
|
108
|
+
message: init.message,
|
|
109
|
+
...init.details !== void 0 ? { details: init.details } : {}
|
|
110
|
+
}) };
|
|
111
|
+
static rateLimited(retryAfterMs, details) {
|
|
112
|
+
return new LinearAgentError({
|
|
113
|
+
code: "RATELIMITED",
|
|
114
|
+
message: "Linear rate limit exceeded",
|
|
115
|
+
transient: true,
|
|
116
|
+
retryAfterMs,
|
|
117
|
+
...details !== void 0 ? { details } : {}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
static network(detail, details) {
|
|
121
|
+
return new LinearAgentError({
|
|
122
|
+
code: "NETWORK_ERROR",
|
|
123
|
+
message: detail,
|
|
124
|
+
transient: true,
|
|
125
|
+
...details !== void 0 ? { details } : {}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
static usage(detail) {
|
|
129
|
+
return new LinearAgentError({
|
|
130
|
+
code: "USAGE_ERROR",
|
|
131
|
+
message: detail
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
static generic(detail) {
|
|
135
|
+
return new LinearAgentError({
|
|
136
|
+
code: "GENERIC_ERROR",
|
|
137
|
+
message: detail
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
//#endregion
|
|
142
|
+
export { LinearAgentError };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
//#region src/core/errors/exit-codes.ts
|
|
2
|
+
/**
|
|
3
|
+
* Canonical numeric exit codes per CONTEXT.md § Exit Code Taxonomy.
|
|
4
|
+
*
|
|
5
|
+
* Stays below 64 to avoid POSIX 125+ shell-reserved codes (clig.dev,
|
|
6
|
+
* Wikipedia § Exit status). Agent runtimes can build retry logic on these
|
|
7
|
+
* because every code is documented and stable.
|
|
8
|
+
*/
|
|
9
|
+
const EXIT_CODES = {
|
|
10
|
+
SUCCESS: 0,
|
|
11
|
+
GENERIC: 1,
|
|
12
|
+
USAGE: 2,
|
|
13
|
+
WORKSPACE: 10,
|
|
14
|
+
AUTH: 11,
|
|
15
|
+
VALIDATION: 12,
|
|
16
|
+
LINEAR_API: 13,
|
|
17
|
+
RATELIMITED: 14,
|
|
18
|
+
NETWORK: 15
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Map an `ErrorCode` to its canonical numeric exit code.
|
|
22
|
+
*
|
|
23
|
+
* Implemented as an exhaustive switch with a `_exhaustive: never` guard in
|
|
24
|
+
* the default branch — adding a new code to `ERROR_CODES` without a
|
|
25
|
+
* corresponding case will fail `tsc --noEmit`. This is exactly the
|
|
26
|
+
* "hard-to-skip" property this kernel needs (T-01-03 in the threat model).
|
|
27
|
+
*/
|
|
28
|
+
function exitCodeFor(code) {
|
|
29
|
+
switch (code) {
|
|
30
|
+
case "WORKSPACE_NOT_RESOLVED":
|
|
31
|
+
case "WORKSPACE_NOT_FOUND":
|
|
32
|
+
case "WORKSPACE_REQUIRED_FOR_WRITE":
|
|
33
|
+
case "WORKSPACE_TOKEN_MISMATCH":
|
|
34
|
+
case "WORKSPACE_ALREADY_EXISTS": return EXIT_CODES.WORKSPACE;
|
|
35
|
+
case "AUTH_INVALID":
|
|
36
|
+
case "CONFIG_PERMISSIONS_TOO_BROAD":
|
|
37
|
+
case "CONFIG_NOT_FOUND": return EXIT_CODES.AUTH;
|
|
38
|
+
case "VALIDATION_FAILED":
|
|
39
|
+
case "INVALID_FIELD": return EXIT_CODES.VALIDATION;
|
|
40
|
+
case "LINEAR_API_ERROR": return EXIT_CODES.LINEAR_API;
|
|
41
|
+
case "RATELIMITED": return EXIT_CODES.RATELIMITED;
|
|
42
|
+
case "NETWORK_ERROR": return EXIT_CODES.NETWORK;
|
|
43
|
+
case "USAGE_ERROR":
|
|
44
|
+
case "VALIDATION_NO_FIELDS":
|
|
45
|
+
case "WORKFLOW_TEAM_REQUIRED":
|
|
46
|
+
case "CONFIRMATION_REQUIRED":
|
|
47
|
+
case "RAW_OPERATION_NOT_FOUND":
|
|
48
|
+
case "RAW_MUTATION_REQUIRES_FLAG":
|
|
49
|
+
case "OPERATION_SUBSCRIPTIONS_UNSUPPORTED":
|
|
50
|
+
case "GRAPHQL_QUERY_FILE_NOT_FOUND":
|
|
51
|
+
case "BATCH_REQUIRES_YES":
|
|
52
|
+
case "INVALID_INCLUDE":
|
|
53
|
+
case "DESCRIBE_COMMAND_NOT_FOUND": return EXIT_CODES.USAGE;
|
|
54
|
+
case "RAW_VARS_INVALID":
|
|
55
|
+
case "GRAPHQL_VALIDATION_FAILED":
|
|
56
|
+
case "BATCH_PLAN_INVALID": return EXIT_CODES.VALIDATION;
|
|
57
|
+
case "WORKFLOW_STATE_NOT_FOUND":
|
|
58
|
+
case "ISSUE_NOT_FOUND":
|
|
59
|
+
case "LABEL_NOT_FOUND":
|
|
60
|
+
case "TEAM_NOT_FOUND":
|
|
61
|
+
case "PROJECT_NOT_FOUND":
|
|
62
|
+
case "CYCLE_NOT_FOUND": return EXIT_CODES.LINEAR_API;
|
|
63
|
+
case "GENERIC_ERROR":
|
|
64
|
+
case "INSTALL_SKILL_BUNDLE_NOT_FOUND":
|
|
65
|
+
case "INSTALL_SKILL_WRITE_FAILED": return EXIT_CODES.GENERIC;
|
|
66
|
+
default: return code;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
export { exitCodeFor };
|