linear-sdk-cli 0.8.2 → 0.9.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/CHANGELOG.md +29 -15
- package/COVERAGE.md +26 -25
- package/MIGRATING.md +1 -1
- package/README.md +170 -9
- package/package.json +6 -6
- package/skills/linear-sdk-cli/SKILL.md +98 -0
- package/skills/linear-sdk-cli/references/auth.md +31 -0
- package/skills/linear-sdk-cli/references/config.md +13 -9
- package/skills/linear-sdk-cli/references/issue.md +3 -2
- package/skills/linear-sdk-cli/references/release-note.md +148 -0
- package/skills/linear-sdk-cli/references/release-pipeline.md +137 -0
- package/skills/linear-sdk-cli/references/release-stage.md +121 -0
- package/skills/linear-sdk-cli/references/release.md +348 -0
- package/src/cli.ts +8 -0
- package/src/commands/issue.ts +9 -3
- package/src/commands/meta.ts +82 -2
- package/src/commands/release-note.ts +90 -0
- package/src/commands/release-pipeline.ts +122 -0
- package/src/commands/release-stage.ts +100 -0
- package/src/commands/release.ts +254 -0
- package/src/config.ts +254 -6
- package/src/lib/keyring.ts +28 -0
- package/src/lib/options.ts +18 -0
- package/src/lib/output-shapes.ts +68 -1
- package/src/lib/release-input.ts +138 -0
- package/src/services/issue.ts +12 -3
- package/src/services/release.ts +691 -0
package/src/config.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Configuration resolution.
|
|
3
3
|
*
|
|
4
4
|
* Two tiers with different trust boundaries:
|
|
5
|
-
* - Non-secret settings (team, workspace, sort): flag > env > project
|
|
5
|
+
* - Non-secret settings (team, workspace, project, sort): flag > env > project
|
|
6
6
|
* config > selected workspace profile (team only) > user config
|
|
7
7
|
* (`~/.config/linear/config.toml`) > the reference CLI's global config
|
|
8
8
|
* (`~/.config/linear/linear.toml`, read-only).
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
export interface RawSettings {
|
|
69
69
|
team?: string;
|
|
70
70
|
workspace?: string;
|
|
71
|
+
project?: string;
|
|
71
72
|
sort?: string;
|
|
72
73
|
apiKey?: string;
|
|
73
74
|
accessToken?: string;
|
|
@@ -102,7 +103,7 @@ export interface SettingOrigin {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
/** The non-secret settings, each with its provenance. */
|
|
105
|
-
export type SettingOrigins = Record<"team" | "workspace" | "sort", SettingOrigin>;
|
|
106
|
+
export type SettingOrigins = Record<"team" | "workspace" | "project" | "sort", SettingOrigin>;
|
|
106
107
|
|
|
107
108
|
export interface ResolvedConfig {
|
|
108
109
|
apiKey?: string;
|
|
@@ -130,6 +131,8 @@ export interface ResolvedConfig {
|
|
|
130
131
|
team?: string;
|
|
131
132
|
/** Non-secret display workspace setting (separate from credentialWorkspace). */
|
|
132
133
|
workspace?: string;
|
|
134
|
+
/** Default Linear project name or id for issue creation only. */
|
|
135
|
+
project?: string;
|
|
133
136
|
sort: string;
|
|
134
137
|
/** Where `sort` came from, so an invalid value can be blamed precisely. */
|
|
135
138
|
sortSource: ConfigSource;
|
|
@@ -215,7 +218,7 @@ interface UserConfig {
|
|
|
215
218
|
workspaces: Record<string, WorkspaceEntry>;
|
|
216
219
|
}
|
|
217
220
|
|
|
218
|
-
type NonSecretSetting =
|
|
221
|
+
type NonSecretSetting = keyof SettingOrigins;
|
|
219
222
|
type SettingKeys = Partial<Record<NonSecretSetting, string>>;
|
|
220
223
|
|
|
221
224
|
interface FileSettings {
|
|
@@ -299,6 +302,7 @@ function settingsFromParsed(parsed: Record<string, unknown>): FileSettings {
|
|
|
299
302
|
const found = [
|
|
300
303
|
read("team", ["team", "team_id"]),
|
|
301
304
|
read("workspace", ["workspace"]),
|
|
305
|
+
read("project", ["project"]),
|
|
302
306
|
read("sort", ["sort", "issue_sort"]),
|
|
303
307
|
];
|
|
304
308
|
const settings: RawSettings = {};
|
|
@@ -468,6 +472,7 @@ function fromEnv(env: NodeJS.ProcessEnv): RawSettings {
|
|
|
468
472
|
accessToken: env.LINEAR_ACCESS_TOKEN,
|
|
469
473
|
team: env.LINEAR_TEAM || env.LINEAR_TEAM_ID,
|
|
470
474
|
workspace: env.LINEAR_WORKSPACE,
|
|
475
|
+
project: env.LINEAR_PROJECT,
|
|
471
476
|
sort: env.LINEAR_ISSUE_SORT,
|
|
472
477
|
};
|
|
473
478
|
}
|
|
@@ -645,7 +650,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
645
650
|
["user", user.settings, userPath, user.settingKeys],
|
|
646
651
|
["global", global.settings, globalPath, global.keys],
|
|
647
652
|
];
|
|
648
|
-
const pick =
|
|
653
|
+
const pick = (key: NonSecretSetting): string | undefined =>
|
|
649
654
|
tiers.find(([, settings]) => settings[key] !== undefined)?.[1][key];
|
|
650
655
|
|
|
651
656
|
const profileTeam = workspaceProfile ? user.workspaces[workspaceProfile]?.team : undefined;
|
|
@@ -664,7 +669,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
664
669
|
];
|
|
665
670
|
|
|
666
671
|
/** Which tier `pick` took the value from, and which file if it was one. */
|
|
667
|
-
const originOf = (key:
|
|
672
|
+
const originOf = (key: NonSecretSetting): SettingOrigin => {
|
|
668
673
|
const candidates = key === "team" ? teamTiers : tiers;
|
|
669
674
|
const hit = candidates.find(([, settings]) => settings[key] !== undefined);
|
|
670
675
|
if (!hit) return { source: "none" };
|
|
@@ -678,6 +683,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
678
683
|
const origins: SettingOrigins = {
|
|
679
684
|
team: originOf("team"),
|
|
680
685
|
workspace: originOf("workspace"),
|
|
686
|
+
project: originOf("project"),
|
|
681
687
|
sort: originOf("sort"),
|
|
682
688
|
};
|
|
683
689
|
|
|
@@ -696,6 +702,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
|
|
|
696
702
|
// The display setting walks every tier. Credential selection above uses
|
|
697
703
|
// the flag, env, and project tiers, then the auth-specific user default.
|
|
698
704
|
workspace: pick("workspace"),
|
|
705
|
+
project: pick("project"),
|
|
699
706
|
sort: pick("sort") ?? "priority",
|
|
700
707
|
sortSource: origins.sort.source,
|
|
701
708
|
origins,
|
|
@@ -958,6 +965,246 @@ function workspacesTable(obj: Record<string, unknown>): Record<string, Workspace
|
|
|
958
965
|
return obj.workspaces as Record<string, WorkspaceTable>;
|
|
959
966
|
}
|
|
960
967
|
|
|
968
|
+
interface ProfileRename {
|
|
969
|
+
from: string;
|
|
970
|
+
to: string;
|
|
971
|
+
phase: "prepared" | "committed";
|
|
972
|
+
keyring: boolean;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export interface RenameProfileResult {
|
|
976
|
+
path: string;
|
|
977
|
+
storage: CredentialStorage;
|
|
978
|
+
team: string | null;
|
|
979
|
+
defaultWorkspace: string | null;
|
|
980
|
+
referenceCredentialsPath: string | null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Move an API-key profile without login/logout or revocation. The private config
|
|
985
|
+
* contains a non-secret recovery marker until all stores agree. Copy, verify,
|
|
986
|
+
* commit metadata, update the shared reference list, then delete the old secret.
|
|
987
|
+
* Retrying the same command resumes an interrupted operation; never overwrite a
|
|
988
|
+
* destination belonging to another operation, even when its key happens to match.
|
|
989
|
+
* OAuth lifecycles are deliberately excluded (including dormant fallback records).
|
|
990
|
+
*/
|
|
991
|
+
export async function renameApiKeyProfile(
|
|
992
|
+
from: string,
|
|
993
|
+
to: string,
|
|
994
|
+
validate: (apiKey: string) => Promise<void>,
|
|
995
|
+
): Promise<RenameProfileResult> {
|
|
996
|
+
const slugPattern = /^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/;
|
|
997
|
+
if (!slugPattern.test(from) || !slugPattern.test(to) || from === to)
|
|
998
|
+
throw new CliError("Pass two different lowercase Linear workspace URL slugs.", "usage");
|
|
999
|
+
const path = userConfigPath();
|
|
1000
|
+
const referencePath = referenceCredentialsPath();
|
|
1001
|
+
const lock = acquireUserConfigLock(path);
|
|
1002
|
+
const retry = `linear auth rename ${from} ${to}`;
|
|
1003
|
+
let started = false;
|
|
1004
|
+
let step = "save the recovery marker in the private user config";
|
|
1005
|
+
try {
|
|
1006
|
+
const obj = readUserObject(path);
|
|
1007
|
+
const ws = workspacesTable(obj);
|
|
1008
|
+
const pending = obj.api_key_profile_rename as ProfileRename | undefined;
|
|
1009
|
+
if (
|
|
1010
|
+
pending &&
|
|
1011
|
+
(pending.from !== from ||
|
|
1012
|
+
pending.to !== to ||
|
|
1013
|
+
!["prepared", "committed"].includes(pending.phase) ||
|
|
1014
|
+
typeof pending.keyring !== "boolean")
|
|
1015
|
+
)
|
|
1016
|
+
throw new CliError(
|
|
1017
|
+
"Another profile rename is pending. Complete the original `auth rename <old-slug> <new-slug>` command first (see api_key_profile_rename in the private user config).",
|
|
1018
|
+
"usage",
|
|
1019
|
+
);
|
|
1020
|
+
const user = readUserConfig(path);
|
|
1021
|
+
if (
|
|
1022
|
+
(pending?.phase === "prepared" && Object.hasOwn(ws, to)) ||
|
|
1023
|
+
(pending?.phase === "committed" && Object.hasOwn(ws, from))
|
|
1024
|
+
)
|
|
1025
|
+
throw new CliError(
|
|
1026
|
+
"Profile metadata conflicts with the pending rename. No credentials were removed.",
|
|
1027
|
+
"runtime",
|
|
1028
|
+
);
|
|
1029
|
+
const selected = pending?.phase === "committed" ? to : from;
|
|
1030
|
+
const entry = user.workspaces[selected];
|
|
1031
|
+
if (!entry || !Object.hasOwn(user.workspaces, selected))
|
|
1032
|
+
throw new CliError(`Workspace '${selected}' is not configured.`, "not_found");
|
|
1033
|
+
if (entry.oauth || entry.app || entry.appFileCredential !== undefined)
|
|
1034
|
+
throw new CliError(
|
|
1035
|
+
"`auth rename` supports API-key profiles only. OAuth user sessions rotate refresh tokens; OAuth app profiles bind organization identity and acquire scoped tokens. Neither lifecycle is migrated by this command. Credentials and grants were left unchanged.",
|
|
1036
|
+
"usage",
|
|
1037
|
+
);
|
|
1038
|
+
if (!pending && Object.hasOwn(user.workspaces, to))
|
|
1039
|
+
throw new CliError(
|
|
1040
|
+
`Destination workspace '${to}' is already configured. Nothing changed.`,
|
|
1041
|
+
"usage",
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
const backend = keyring();
|
|
1045
|
+
const readAccount = (account: string): string | null => {
|
|
1046
|
+
try {
|
|
1047
|
+
return backend?.get(account) ?? null;
|
|
1048
|
+
} catch {
|
|
1049
|
+
throw new CliError(
|
|
1050
|
+
"Could not inspect the system keyring. No credentials were removed.",
|
|
1051
|
+
"runtime",
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
// Probe exact names, including unlisted destinations and inactive OAuth records.
|
|
1056
|
+
for (const slug of [from, to]) {
|
|
1057
|
+
if (readAccount(oauthAccount(slug)) !== null || readAccount(`app:${slug}`) !== null)
|
|
1058
|
+
throw new CliError(
|
|
1059
|
+
`Workspace '${slug}' has an OAuth keyring record. API-key-only rename cannot move or replace it. Credentials and grants were left unchanged.`,
|
|
1060
|
+
"usage",
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
const oldKey = readAccount(from);
|
|
1064
|
+
const destinationKey = readAccount(to);
|
|
1065
|
+
if (!pending && destinationKey !== null)
|
|
1066
|
+
throw new CliError(
|
|
1067
|
+
`Destination '${to}' already exists in the system keyring. Nothing changed.`,
|
|
1068
|
+
"usage",
|
|
1069
|
+
);
|
|
1070
|
+
const apiKey = entry.apiKey ?? (selected === from ? oldKey : destinationKey);
|
|
1071
|
+
if (!apiKey)
|
|
1072
|
+
throw new CliError(
|
|
1073
|
+
"The selected stored API key is unavailable. No credentials were removed.",
|
|
1074
|
+
"auth",
|
|
1075
|
+
);
|
|
1076
|
+
if (
|
|
1077
|
+
(oldKey !== null && oldKey !== apiKey) ||
|
|
1078
|
+
(destinationKey !== null && destinationKey !== apiKey)
|
|
1079
|
+
)
|
|
1080
|
+
throw new CliError(
|
|
1081
|
+
"A rename credential changed or conflicts with the stored profile. No credentials were removed.",
|
|
1082
|
+
"runtime",
|
|
1083
|
+
);
|
|
1084
|
+
const movesKeyring = pending?.keyring ?? oldKey !== null;
|
|
1085
|
+
if (pending && !movesKeyring && (oldKey !== null || destinationKey !== null))
|
|
1086
|
+
throw new CliError(
|
|
1087
|
+
"Credential storage changed since this rename started. No credentials were removed.",
|
|
1088
|
+
"runtime",
|
|
1089
|
+
);
|
|
1090
|
+
if (movesKeyring && !backend)
|
|
1091
|
+
throw new CliError(
|
|
1092
|
+
"The original system keyring is required to complete this rename.",
|
|
1093
|
+
"runtime",
|
|
1094
|
+
);
|
|
1095
|
+
if (movesKeyring && !backend?.create)
|
|
1096
|
+
throw new CliError(
|
|
1097
|
+
"This keyring has no atomic create-only operation, so API-key profile rename is unsupported for its stored entries. macOS Keychain and file-only profiles are supported. No credentials were changed.",
|
|
1098
|
+
"runtime",
|
|
1099
|
+
);
|
|
1100
|
+
if (pending?.phase === "prepared" && movesKeyring && oldKey === null)
|
|
1101
|
+
throw new CliError(
|
|
1102
|
+
"The original keyring entry is missing. Restore it before retrying the rename.",
|
|
1103
|
+
"runtime",
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
// API keys have no historical organization-ID binding. The exact stored key,
|
|
1107
|
+
// never an invocation override, proves its organization and current URL slug.
|
|
1108
|
+
const configBefore = existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
1109
|
+
const referenceBefore = existsSync(referencePath) ? readFileSync(referencePath, "utf8") : null;
|
|
1110
|
+
await validate(apiKey);
|
|
1111
|
+
if (
|
|
1112
|
+
(existsSync(path) ? readFileSync(path, "utf8") : null) !== configBefore ||
|
|
1113
|
+
(existsSync(referencePath) ? readFileSync(referencePath, "utf8") : null) !==
|
|
1114
|
+
referenceBefore ||
|
|
1115
|
+
readAccount(from) !== oldKey ||
|
|
1116
|
+
readAccount(to) !== destinationKey ||
|
|
1117
|
+
[from, to].some(
|
|
1118
|
+
(slug) => readAccount(oauthAccount(slug)) !== null || readAccount(`app:${slug}`) !== null,
|
|
1119
|
+
)
|
|
1120
|
+
)
|
|
1121
|
+
throw new CliError(
|
|
1122
|
+
"Profile storage changed during validation. Retry the rename; no credentials were removed.",
|
|
1123
|
+
"runtime",
|
|
1124
|
+
);
|
|
1125
|
+
|
|
1126
|
+
const state: ProfileRename = pending ?? { from, to, phase: "prepared", keyring: movesKeyring };
|
|
1127
|
+
started = true;
|
|
1128
|
+
if (!pending) {
|
|
1129
|
+
obj.api_key_profile_rename = state;
|
|
1130
|
+
writeUserObject(path, obj);
|
|
1131
|
+
}
|
|
1132
|
+
if (state.phase === "prepared") {
|
|
1133
|
+
if (movesKeyring) {
|
|
1134
|
+
step = "copy and verify the destination API key in the system keyring";
|
|
1135
|
+
// A prior prepared attempt may already have copied this exact key.
|
|
1136
|
+
// Otherwise the backend itself must reject a concurrent destination.
|
|
1137
|
+
if (destinationKey === null) backend!.create!(to, apiKey);
|
|
1138
|
+
if (readAccount(to) !== apiKey) throw new Error("Keyring copy could not be verified");
|
|
1139
|
+
}
|
|
1140
|
+
ws[to] = { ...(ws[from] ?? {}) };
|
|
1141
|
+
if (!entry.apiKey) ws[to]!.keyring = true;
|
|
1142
|
+
delete ws[from];
|
|
1143
|
+
if (user.defaultWorkspace === from) obj.default_workspace = to;
|
|
1144
|
+
if (obj.workspace === from) obj.workspace = to;
|
|
1145
|
+
obj.api_key_profile_rename = { ...state, phase: "committed" };
|
|
1146
|
+
step = "commit the renamed profile in the private user config";
|
|
1147
|
+
writeUserObject(path, obj);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Preserve unknown reference settings. Its inline-key format is never read
|
|
1151
|
+
// as a credential source and must never be rewritten as a workspace list.
|
|
1152
|
+
let updatedReference: string | null = null;
|
|
1153
|
+
const reference = readReferenceCredentials(referencePath);
|
|
1154
|
+
if (reference.workspaces.includes(from)) {
|
|
1155
|
+
const referenceObj = readUserObject(referencePath);
|
|
1156
|
+
referenceObj.workspaces = [
|
|
1157
|
+
...new Set(reference.workspaces.map((s) => (s === from ? to : s))),
|
|
1158
|
+
];
|
|
1159
|
+
if (referenceObj.default === from) referenceObj.default = to;
|
|
1160
|
+
step = "update the reference CLI's shared credentials list";
|
|
1161
|
+
writeUserObject(referencePath, referenceObj);
|
|
1162
|
+
updatedReference = referencePath;
|
|
1163
|
+
}
|
|
1164
|
+
if (movesKeyring) {
|
|
1165
|
+
step = "remove the old API-key entry from the system keyring";
|
|
1166
|
+
if (
|
|
1167
|
+
readAccount(to) !== apiKey ||
|
|
1168
|
+
(readAccount(from) !== null && readAccount(from) !== apiKey)
|
|
1169
|
+
)
|
|
1170
|
+
throw new Error("Keyring changed before cleanup");
|
|
1171
|
+
backend!.delete(from);
|
|
1172
|
+
if (readAccount(from) !== null) throw new Error("Old keyring entry remains");
|
|
1173
|
+
}
|
|
1174
|
+
step = "clear the recovery marker in the private user config";
|
|
1175
|
+
delete obj.api_key_profile_rename;
|
|
1176
|
+
writeUserObject(path, obj);
|
|
1177
|
+
return {
|
|
1178
|
+
path,
|
|
1179
|
+
storage: entry.apiKey ? "file" : "keychain",
|
|
1180
|
+
team: asString(ws[to]?.team) ?? null,
|
|
1181
|
+
defaultWorkspace:
|
|
1182
|
+
asString(obj.default_workspace) ??
|
|
1183
|
+
(reference.defaultWorkspace === from ? to : reference.defaultWorkspace) ??
|
|
1184
|
+
null,
|
|
1185
|
+
referenceCredentialsPath: updatedReference,
|
|
1186
|
+
};
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
if (!started) throw err;
|
|
1189
|
+
// Store errors may contain secrets. Keep a stable recovery instruction and
|
|
1190
|
+
// retain the marker; at least one validated copy always precedes deletion.
|
|
1191
|
+
throw new CliError(
|
|
1192
|
+
`Profile rename is incomplete: could not ${step}. No grant was revoked. A credential copy is retained under '${from}' or '${to}'. Rerun \`${retry}\` to resume; do not log out either profile during recovery.`,
|
|
1193
|
+
"runtime",
|
|
1194
|
+
);
|
|
1195
|
+
} finally {
|
|
1196
|
+
releaseLockMarker(lock);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** Read-only report: discovery is bounded to cwd plus explicitly supplied files. */
|
|
1201
|
+
export function profileRenameProjectUpdates(from: string, to: string, paths: string[]) {
|
|
1202
|
+
return [...new Set(paths)].flatMap((path) => {
|
|
1203
|
+
const { settings } = readTomlFile(path);
|
|
1204
|
+
return settings.workspace === from ? [{ path, key: "workspace", from, to }] : [];
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
|
|
961
1208
|
export interface WriteCredentialResult {
|
|
962
1209
|
/** Our config file — always written (the slug list lives there). */
|
|
963
1210
|
path: string;
|
|
@@ -1708,7 +1955,7 @@ export function redactKey(key: string | undefined): string {
|
|
|
1708
1955
|
// ---------------------------------------------------------------------------
|
|
1709
1956
|
|
|
1710
1957
|
/** The settings `config set` / `config init` will write to a project file. */
|
|
1711
|
-
export const SETTABLE_KEYS = ["team", "workspace", "sort"] as const;
|
|
1958
|
+
export const SETTABLE_KEYS = ["team", "workspace", "sort", "project"] as const;
|
|
1712
1959
|
export type SettableKey = (typeof SETTABLE_KEYS)[number];
|
|
1713
1960
|
|
|
1714
1961
|
/**
|
|
@@ -1719,6 +1966,7 @@ export type SettableKey = (typeof SETTABLE_KEYS)[number];
|
|
|
1719
1966
|
const KEY_ALIASES: Record<SettableKey, readonly string[]> = {
|
|
1720
1967
|
team: ["team", "team_id"],
|
|
1721
1968
|
workspace: ["workspace"],
|
|
1969
|
+
project: ["project"],
|
|
1722
1970
|
sort: ["sort", "issue_sort"],
|
|
1723
1971
|
};
|
|
1724
1972
|
|
package/src/lib/keyring.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface KeyringBackend {
|
|
|
35
35
|
get(account: string): string | null;
|
|
36
36
|
/** Create or replace the entry. */
|
|
37
37
|
set(account: string, secret: string): void;
|
|
38
|
+
/** Atomically create a new entry, failing if it exists. Absent when unsupported. */
|
|
39
|
+
create?(account: string, secret: string): void;
|
|
38
40
|
/** Remove the entry. Returns false when there was none. */
|
|
39
41
|
delete(account: string): boolean;
|
|
40
42
|
}
|
|
@@ -118,6 +120,26 @@ export const macosKeychain: KeyringBackend = {
|
|
|
118
120
|
}
|
|
119
121
|
},
|
|
120
122
|
|
|
123
|
+
create(account, secret) {
|
|
124
|
+
// Omitting -U makes the Keychain enforce uniqueness at insertion time.
|
|
125
|
+
// Keep the credential on stdin, just like set(), but never replace a racer.
|
|
126
|
+
const line =
|
|
127
|
+
[
|
|
128
|
+
"add-generic-password",
|
|
129
|
+
"-a",
|
|
130
|
+
quoteForSecurity(account, "workspace slug"),
|
|
131
|
+
"-s",
|
|
132
|
+
KEYRING_SERVICE,
|
|
133
|
+
"-w",
|
|
134
|
+
quoteForSecurity(secret, "API key"),
|
|
135
|
+
].join(" ") + "\n";
|
|
136
|
+
const result = runSecurity(["-i"], line);
|
|
137
|
+
if (result.status !== 0)
|
|
138
|
+
throw new KeyringError(
|
|
139
|
+
"Could not create the destination keychain entry; it may already exist or the keychain is unavailable.",
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
|
|
121
143
|
delete(account) {
|
|
122
144
|
const r = runSecurity(["delete-generic-password", "-a", account, "-s", KEYRING_SERVICE]);
|
|
123
145
|
if (r.status === 0) return true;
|
|
@@ -145,6 +167,8 @@ function runSecretTool(args: string[], input?: string) {
|
|
|
145
167
|
export const linuxSecretService: KeyringBackend = {
|
|
146
168
|
name: "secret-service",
|
|
147
169
|
label: "system keyring (Secret Service)",
|
|
170
|
+
// secret-tool store replaces matching items and exposes no atomic create-only
|
|
171
|
+
// operation. Deliberately omit create rather than emulate it with get + set.
|
|
148
172
|
|
|
149
173
|
get(account) {
|
|
150
174
|
const r = runSecretTool(["lookup", "service", KEYRING_SERVICE, "account", account]);
|
|
@@ -265,6 +289,10 @@ export function memoryKeyring(initial: Record<string, string> = {}): KeyringBack
|
|
|
265
289
|
store,
|
|
266
290
|
get: (a) => store.get(a) ?? null,
|
|
267
291
|
set: (a, s) => void store.set(a, s),
|
|
292
|
+
create: (a, s) => {
|
|
293
|
+
if (store.has(a)) throw new KeyringError("Destination keyring entry already exists.");
|
|
294
|
+
store.set(a, s);
|
|
295
|
+
},
|
|
268
296
|
delete: (a) => store.delete(a),
|
|
269
297
|
};
|
|
270
298
|
}
|
package/src/lib/options.ts
CHANGED
|
@@ -359,6 +359,16 @@ export function addGlobalOptions(program: Command): Command {
|
|
|
359
359
|
|
|
360
360
|
/** Commands whose output `--fields` projects: everything that renders a table or a detail block. */
|
|
361
361
|
export const FIELDS_COMMANDS: ReadonlySet<string> = new Set([
|
|
362
|
+
"release list",
|
|
363
|
+
"release view",
|
|
364
|
+
"release issues",
|
|
365
|
+
"release links",
|
|
366
|
+
"release-pipeline list",
|
|
367
|
+
"release-pipeline view",
|
|
368
|
+
"release-stage list",
|
|
369
|
+
"release-stage view",
|
|
370
|
+
"release-note list",
|
|
371
|
+
"release-note view",
|
|
362
372
|
"whoami",
|
|
363
373
|
"auth whoami",
|
|
364
374
|
"auth status",
|
|
@@ -424,6 +434,12 @@ export const FIELDS_COMMANDS: ReadonlySet<string> = new Set([
|
|
|
424
434
|
|
|
425
435
|
/** Commands with bounded lists: `--limit <n>` caps them, `--all` (or `--limit 0`) exhausts them. */
|
|
426
436
|
export const LIMIT_COMMANDS: ReadonlySet<string> = new Set([
|
|
437
|
+
"release list",
|
|
438
|
+
"release issues",
|
|
439
|
+
"release links",
|
|
440
|
+
"release-pipeline list",
|
|
441
|
+
"release-stage list",
|
|
442
|
+
"release-note list",
|
|
427
443
|
"changelog",
|
|
428
444
|
"attachment list",
|
|
429
445
|
"comment list",
|
|
@@ -501,6 +517,8 @@ export const TEAM_COMMANDS: ReadonlySet<string> = new Set([
|
|
|
501
517
|
|
|
502
518
|
/** Commands whose confirmation prompt is bypassed by `--yes`. */
|
|
503
519
|
export const YES_COMMANDS: ReadonlySet<string> = new Set([
|
|
520
|
+
"release remove-issue",
|
|
521
|
+
"release-note delete",
|
|
504
522
|
"attachment delete",
|
|
505
523
|
"auth logout",
|
|
506
524
|
"comment delete",
|
package/src/lib/output-shapes.ts
CHANGED
|
@@ -68,6 +68,16 @@ import {
|
|
|
68
68
|
} from "../services/team.js";
|
|
69
69
|
import { USER_DETAIL_SHAPE, USER_ROW_SHAPE } from "../services/user.js";
|
|
70
70
|
import { WEBHOOK_DETAIL_SHAPE, WEBHOOK_ROW_SHAPE } from "../services/webhook.js";
|
|
71
|
+
import {
|
|
72
|
+
RELEASE_ROW_SHAPE,
|
|
73
|
+
RELEASE_DETAIL_SHAPE,
|
|
74
|
+
PIPELINE_ROW_SHAPE,
|
|
75
|
+
STAGE_ROW_SHAPE,
|
|
76
|
+
NOTE_ROW_SHAPE,
|
|
77
|
+
NOTE_DETAIL_SHAPE,
|
|
78
|
+
RELEASE_ISSUE_SHAPE,
|
|
79
|
+
RELEASE_LINK_SHAPE,
|
|
80
|
+
} from "../services/release.js";
|
|
71
81
|
import type { UpdateRow } from "../lib/status-update.js";
|
|
72
82
|
import type { SettingOrigin, SettingOrigins } from "../config.js";
|
|
73
83
|
import type { CommandNode } from "./introspect.js";
|
|
@@ -173,6 +183,7 @@ const SETTING_ORIGIN_SHAPE = shape<SettingOrigin>({
|
|
|
173
183
|
const SETTING_ORIGINS_SHAPE = shape<SettingOrigins>({
|
|
174
184
|
team: SETTING_ORIGIN_SHAPE,
|
|
175
185
|
workspace: SETTING_ORIGIN_SHAPE,
|
|
186
|
+
project: SETTING_ORIGIN_SHAPE,
|
|
176
187
|
sort: SETTING_ORIGIN_SHAPE,
|
|
177
188
|
});
|
|
178
189
|
|
|
@@ -186,6 +197,7 @@ const CONFIG_SHOW_SHAPE: ObjectFields = {
|
|
|
186
197
|
workspaceProfile: "string|null",
|
|
187
198
|
team: "string|null",
|
|
188
199
|
workspace: "string|null",
|
|
200
|
+
project: "string|null",
|
|
189
201
|
sort: "string",
|
|
190
202
|
origins: SETTING_ORIGINS_SHAPE,
|
|
191
203
|
userConfigPath: "string",
|
|
@@ -311,6 +323,39 @@ const RAW_API: OutputShape = {
|
|
|
311
323
|
// ---------------------------------------------------------------------------
|
|
312
324
|
|
|
313
325
|
export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
|
|
326
|
+
release: null,
|
|
327
|
+
"release list": list(RELEASE_ROW_SHAPE),
|
|
328
|
+
"release view": object(RELEASE_DETAIL_SHAPE),
|
|
329
|
+
"release create": receipt(RELEASE_DETAIL_SHAPE),
|
|
330
|
+
"release update": receipt(RELEASE_DETAIL_SHAPE),
|
|
331
|
+
"release sync": receipt(RELEASE_DETAIL_SHAPE),
|
|
332
|
+
"release update-version": receipt(RELEASE_DETAIL_SHAPE),
|
|
333
|
+
"release complete": receipt(RELEASE_DETAIL_SHAPE),
|
|
334
|
+
"release issues": list(RELEASE_ISSUE_SHAPE),
|
|
335
|
+
"release links": list(RELEASE_LINK_SHAPE),
|
|
336
|
+
"release add-issue": receipt({
|
|
337
|
+
id: "string",
|
|
338
|
+
releaseId: "string",
|
|
339
|
+
issueId: "string",
|
|
340
|
+
identifier: "string",
|
|
341
|
+
}),
|
|
342
|
+
"release remove-issue": receipt({ releaseId: "string", issue: "string", removed: "boolean" }),
|
|
343
|
+
"release-pipeline": null,
|
|
344
|
+
"release-pipeline list": list(PIPELINE_ROW_SHAPE),
|
|
345
|
+
"release-pipeline view": object(PIPELINE_ROW_SHAPE),
|
|
346
|
+
"release-pipeline create": receipt(PIPELINE_ROW_SHAPE),
|
|
347
|
+
"release-pipeline update": receipt(PIPELINE_ROW_SHAPE),
|
|
348
|
+
"release-stage": null,
|
|
349
|
+
"release-stage list": list(STAGE_ROW_SHAPE),
|
|
350
|
+
"release-stage view": object(STAGE_ROW_SHAPE),
|
|
351
|
+
"release-stage create": receipt(STAGE_ROW_SHAPE),
|
|
352
|
+
"release-stage update": receipt(STAGE_ROW_SHAPE),
|
|
353
|
+
"release-note": null,
|
|
354
|
+
"release-note list": list(NOTE_ROW_SHAPE),
|
|
355
|
+
"release-note view": object(NOTE_DETAIL_SHAPE),
|
|
356
|
+
"release-note create": receipt(NOTE_DETAIL_SHAPE),
|
|
357
|
+
"release-note update": receipt(NOTE_DETAIL_SHAPE),
|
|
358
|
+
"release-note delete": receipt({ id: "string", deleted: "boolean" }),
|
|
314
359
|
api: RAW_API,
|
|
315
360
|
|
|
316
361
|
changelog: list({
|
|
@@ -360,6 +405,22 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
|
|
|
360
405
|
teamMetadataRemoved: "boolean",
|
|
361
406
|
}),
|
|
362
407
|
"auth migrate": receipt({ success: "boolean", migrated: ["string"], path: "string" }),
|
|
408
|
+
"auth rename": receipt({
|
|
409
|
+
success: "boolean",
|
|
410
|
+
from: "string",
|
|
411
|
+
workspace: "string",
|
|
412
|
+
credentialType: "string",
|
|
413
|
+
organization: { id: "string", name: "string", urlKey: "string" },
|
|
414
|
+
path: "string",
|
|
415
|
+
storage: "string",
|
|
416
|
+
team: "string|null",
|
|
417
|
+
defaultWorkspace: "string|null",
|
|
418
|
+
referenceCredentialsPath: "string|null",
|
|
419
|
+
checkedProjectConfigs: ["string"],
|
|
420
|
+
projectConfigUpdates: [{ path: "string", key: "string", from: "string", to: "string" }],
|
|
421
|
+
globalConfigUpdates: [{ path: "string", key: "string", from: "string", to: "string" }],
|
|
422
|
+
otherProjects: "string",
|
|
423
|
+
}),
|
|
363
424
|
"auth status": object({
|
|
364
425
|
authenticated: "boolean",
|
|
365
426
|
credentialType: "string|null",
|
|
@@ -393,7 +454,13 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
|
|
|
393
454
|
completion: { kind: "none", note: "always prints a shell script; --json has no effect" },
|
|
394
455
|
|
|
395
456
|
config: runs("config show", object(CONFIG_SHOW_SHAPE)),
|
|
396
|
-
"config init": receipt({
|
|
457
|
+
"config init": receipt({
|
|
458
|
+
success: "boolean",
|
|
459
|
+
path: "string",
|
|
460
|
+
team: "string",
|
|
461
|
+
"sort?": "string",
|
|
462
|
+
"project?": "string",
|
|
463
|
+
}),
|
|
397
464
|
"config set": receipt({
|
|
398
465
|
success: "boolean",
|
|
399
466
|
path: "string",
|