atmn 1.1.25 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +120 -0
  2. package/dist/bin.js +23102 -0
  3. package/dist/index.js +2906 -0
  4. package/dist/tsconfig.tsbuildinfo +1 -1
  5. package/package.json +27 -74
  6. package/src/actions/api/callApi.ts +280 -0
  7. package/src/actions/api/registerApiCommands.ts +121 -0
  8. package/src/actions/env/fetchOrgInfo.ts +32 -0
  9. package/src/actions/env/types/orgInfo.ts +18 -0
  10. package/src/actions/env.ts +94 -0
  11. package/src/actions/init/runInit.ts +401 -0
  12. package/src/actions/login/keyless.ts +135 -0
  13. package/src/actions/login.ts +144 -0
  14. package/src/actions/pull/appendPlanVersionFixture.ts +373 -0
  15. package/src/actions/pull/applyPreview.ts +553 -0
  16. package/src/actions/pull/applySettingsPreview.ts +114 -0
  17. package/src/actions/pull/changedFixtureKeys.ts +88 -0
  18. package/src/actions/pull/listSourceFiles.ts +29 -0
  19. package/src/actions/pull/locateFixture.ts +78 -0
  20. package/src/actions/pull/resolveCollectionTarget.ts +198 -0
  21. package/src/actions/pull/rewriteConfig.ts +57 -0
  22. package/src/actions/pull/scaffoldConfig.ts +118 -0
  23. package/src/actions/pull.ts +399 -0
  24. package/src/actions/push/backfillInternalIds.ts +394 -0
  25. package/src/actions/push/deprecatedFields.ts +55 -0
  26. package/src/actions/push.ts +294 -0
  27. package/src/actions/reset/runReset.ts +58 -0
  28. package/src/actions/sandbox/createSandbox.ts +83 -0
  29. package/src/actions/sandbox/deleteSandbox.ts +91 -0
  30. package/src/actions/sandbox/listSandboxes.ts +28 -0
  31. package/src/actions/sandbox/types/sandboxClient.ts +16 -0
  32. package/src/actions/sandbox/useSandbox.ts +157 -0
  33. package/src/actions/sandbox/withSandboxScopeHint.ts +27 -0
  34. package/src/actions/skills/skills.ts +246 -0
  35. package/src/auth/announceAuthorizationUrl.ts +26 -0
  36. package/src/auth/browser/openSystemBrowser.ts +7 -0
  37. package/src/auth/browser/tryOpenBrowser.ts +17 -0
  38. package/src/auth/browser/watchLauncher.ts +46 -0
  39. package/src/auth/buildAuthorizationUrl.ts +36 -0
  40. package/src/auth/callbackPages.ts +126 -0
  41. package/src/auth/createOrgApiKeys.ts +46 -0
  42. package/src/auth/keyless.ts +119 -0
  43. package/src/auth/oauthConfig.ts +63 -0
  44. package/src/auth/runOAuthFlow.ts +230 -0
  45. package/src/auth/types/browserOpener.ts +5 -0
  46. package/src/auth/types/impersonationTokens.ts +23 -0
  47. package/src/auth/types/oauthTokens.ts +13 -0
  48. package/src/auth/types/orgApiKeys.ts +6 -0
  49. package/src/bin.ts +9 -0
  50. package/src/cli.ts +648 -0
  51. package/src/config/configPackageName.ts +9 -0
  52. package/src/config/legacyConfig.ts +25 -0
  53. package/src/config/loadConfig.ts +231 -0
  54. package/src/env/assertSandboxTarget.ts +20 -0
  55. package/src/env/loadEnv.ts +184 -0
  56. package/src/env/resolveTarget.ts +134 -0
  57. package/src/env/sandboxKeyName.ts +18 -0
  58. package/src/generated/apiRoutes.ts +3787 -0
  59. package/src/generated/client.ts +51564 -0
  60. package/src/generated/emit.ts +1163 -0
  61. package/src/generated/emitRuntime.ts +522 -0
  62. package/src/generated/features.ts +146 -0
  63. package/src/generated/labels.ts +29 -0
  64. package/src/generated/licenses.ts +287 -0
  65. package/src/generated/lintRules.ts +2207 -0
  66. package/src/generated/lintRuntime.ts +865 -0
  67. package/src/generated/plans.ts +1628 -0
  68. package/src/generated/referralPrograms.ts +22 -0
  69. package/src/generated/rewards.ts +58 -0
  70. package/src/generated/settings.ts +21 -0
  71. package/src/generated/skills.ts +305 -0
  72. package/src/generated/variants.ts +934 -0
  73. package/src/generated/wire.ts +334 -0
  74. package/src/http/autumnFetch.ts +30 -0
  75. package/src/index.ts +20 -0
  76. package/src/project/chooseConfigDir.ts +41 -0
  77. package/src/project/resolveProject.ts +115 -0
  78. package/src/project/rootMarker.ts +40 -0
  79. package/src/prompt/prompt.ts +186 -0
  80. package/src/prompt/select.ts +162 -0
  81. package/src/render/renderEnv.ts +77 -0
  82. package/src/render/renderPreview.ts +945 -0
  83. package/src/render/renderSandboxes.ts +92 -0
  84. package/src/render/stripTerminalControls.ts +19 -0
  85. package/src/repo/findRepoRoot.ts +79 -0
  86. package/src/surgery/appendPropertyEdit.ts +67 -0
  87. package/src/surgery/appendToArray.ts +87 -0
  88. package/src/surgery/appendToBinding.ts +19 -0
  89. package/src/surgery/appendToCollection.ts +40 -0
  90. package/src/surgery/appendToFixtureArray.ts +71 -0
  91. package/src/surgery/arrayBinding.ts +30 -0
  92. package/src/surgery/deleteFixtureLiteral.ts +81 -0
  93. package/src/surgery/deleteReference.ts +48 -0
  94. package/src/surgery/ensureBuilderImport.ts +65 -0
  95. package/src/surgery/findFixture.ts +238 -0
  96. package/src/surgery/fixtureEdit.ts +156 -0
  97. package/src/surgery/fixtureLocation.ts +32 -0
  98. package/src/surgery/insertCollection.ts +86 -0
  99. package/src/surgery/insertFirstProperty.ts +73 -0
  100. package/src/surgery/patchFixtureProperty.ts +152 -0
  101. package/src/surgery/patchSingletonProperty.ts +221 -0
  102. package/src/surgery/replaceFixture.ts +28 -0
  103. package/src/surgery/setFixtureProperty.ts +55 -0
  104. package/src/surgery/staticFixtureRule.ts +48 -0
  105. package/src/version.ts +5 -0
  106. package/dist/cli.js +0 -146296
  107. package/dist/compose/index.js +0 -122
  108. package/dist/src/compose/builders/builderFunctions.d.ts +0 -84
  109. package/dist/src/compose/builders/rewardFunctions.d.ts +0 -5
  110. package/dist/src/compose/builders/variantFunctions.d.ts +0 -2
  111. package/dist/src/compose/index.d.ts +0 -19
  112. package/dist/src/compose/models/featureModels.d.ts +0 -262
  113. package/dist/src/compose/models/index.d.ts +0 -3
  114. package/dist/src/compose/models/planModels.d.ts +0 -562
  115. package/dist/src/compose/models/rewardModels.d.ts +0 -52
  116. package/dist/src/compose/models/variantModels.d.ts +0 -34
  117. package/readme.md +0 -186
@@ -0,0 +1,157 @@
1
+ import {
2
+ readEnvFileValue,
3
+ removeEnvValues,
4
+ writeEnvValues,
5
+ } from "../../env/loadEnv";
6
+ import { SANDBOX_PIN_NAME, sandboxKeyName } from "../../env/sandboxKeyName";
7
+ import { done, type Prompter } from "../../prompt/prompt";
8
+ import { type Choice, matchChoice, select } from "../../prompt/select";
9
+ import { renderSandboxes, type SandboxRow } from "../../render/renderSandboxes";
10
+ import { stripTerminalControls } from "../../render/stripTerminalControls";
11
+ import type { OrgInfo } from "../env/types/orgInfo";
12
+ import type { UseSandboxClient } from "./types/sandboxClient";
13
+
14
+ export type SandboxUseOptions = {
15
+ /** Absent only for `--clear`, which touches nothing remote. */
16
+ client?: UseSandboxClient;
17
+ /** The main organization, as `/organization/me` answered for the main key. */
18
+ org?: Pick<OrgInfo, "id" | "name" | "slug">;
19
+ /** A name or an id; asked for when absent and interactive. */
20
+ query?: string;
21
+ /** Drop the pin instead of setting one. */
22
+ clear?: boolean;
23
+ json?: boolean;
24
+ /** Where the .env lives; the first existing file is used, else the first dir. */
25
+ envDirs: string[];
26
+ prompter: Prompter;
27
+ };
28
+
29
+ export type SandboxUseResult = {
30
+ organization: Pick<OrgInfo, "id" | "name" | "slug">;
31
+ sandbox: { id: string; name: string; slug: string };
32
+ keyName: string;
33
+ keyMinted: boolean;
34
+ envPath: string;
35
+ notes: string[];
36
+ };
37
+
38
+ const choicesOf = ({
39
+ sandboxes,
40
+ currentId,
41
+ }: {
42
+ sandboxes: SandboxRow[];
43
+ currentId: string | undefined;
44
+ }): Choice[] =>
45
+ sandboxes.map((sandbox) => ({
46
+ value: sandbox.id,
47
+ label: stripTerminalControls(sandbox.name),
48
+ detail: sandbox.id,
49
+ current: sandbox.id === currentId,
50
+ }));
51
+
52
+ const notesFor = ({ name }: { name: string }): string[] => [
53
+ `Every atmn command now targets sandbox ${name}.`,
54
+ "Run `atmn env` to confirm; `atmn sandbox use --clear` returns to the main sandbox.",
55
+ "`atmn push` previews only; add --yes to apply.",
56
+ ];
57
+
58
+ /**
59
+ * The pin is the only per-project state a sandbox needs: `--sandbox` and the
60
+ * key name derive from it. A key is minted when this machine has none for
61
+ * the sandbox, so a teammate's sandbox is one command away.
62
+ */
63
+ export const runSandboxUse = async ({
64
+ client,
65
+ org,
66
+ query,
67
+ clear = false,
68
+ json = false,
69
+ envDirs,
70
+ prompter,
71
+ }: SandboxUseOptions): Promise<SandboxUseResult | null> => {
72
+ if (clear) {
73
+ const changed = removeEnvValues({
74
+ dirs: envDirs,
75
+ keys: [SANDBOX_PIN_NAME],
76
+ });
77
+ if (json) {
78
+ prompter.write(
79
+ `${JSON.stringify({ cleared: true, envPaths: changed, notes: ["Commands target the main sandbox again."] }, null, 2)}\n`,
80
+ );
81
+ return null;
82
+ }
83
+ prompter.write(
84
+ `${done(`Cleared ${SANDBOX_PIN_NAME}; commands target the main sandbox again.`)}\n`,
85
+ );
86
+ return null;
87
+ }
88
+ if (client === undefined || org === undefined)
89
+ throw new Error("sandbox use needs the main key to list sandboxes.");
90
+
91
+ const { list } = await client.listSandboxes({});
92
+ const currentId = process.env[SANDBOX_PIN_NAME];
93
+ const choices = choicesOf({ sandboxes: list, currentId });
94
+
95
+ let picked: Choice | null;
96
+ if (query !== undefined && query !== "") {
97
+ picked = matchChoice({ choices, query });
98
+ if (picked === null)
99
+ throw new Error(
100
+ `No sandbox named or with id ${JSON.stringify(query)}. Run atmn sandbox list.`,
101
+ );
102
+ } else {
103
+ if (!prompter.interactive)
104
+ prompter.write(
105
+ `${renderSandboxes({ sandboxes: list, currentSandboxId: currentId })}\n`,
106
+ );
107
+ picked = await select({
108
+ prompter,
109
+ choices,
110
+ question: "Which sandbox?",
111
+ flag: "atmn sandbox use <name|id>",
112
+ });
113
+ if (picked === null) return null;
114
+ }
115
+
116
+ const sandbox = list.find((row) => row.id === picked.value);
117
+ if (sandbox === undefined) throw new Error("Sandbox vanished from the list.");
118
+ const keyName = sandboxKeyName({ sandboxId: sandbox.id });
119
+ const name = stripTerminalControls(sandbox.name);
120
+
121
+ const onDisk =
122
+ process.env[keyName] ||
123
+ readEnvFileValue({ dirs: envDirs, key: keyName }) ||
124
+ undefined;
125
+ let keyMinted = false;
126
+ const values: Record<string, string> = { [SANDBOX_PIN_NAME]: sandbox.id };
127
+ if (onDisk === undefined) {
128
+ const minted = await client.createSandboxKey({ id: sandbox.id });
129
+ values[keyName] = minted.secretKey;
130
+ keyMinted = true;
131
+ }
132
+ const envPath = writeEnvValues({ dirs: envDirs, values });
133
+
134
+ const result: SandboxUseResult = {
135
+ organization: { id: org.id, name: org.name, slug: org.slug },
136
+ sandbox: { id: sandbox.id, name: sandbox.name, slug: sandbox.slug },
137
+ keyName,
138
+ keyMinted,
139
+ envPath,
140
+ notes: notesFor({ name }),
141
+ };
142
+
143
+ if (json) {
144
+ prompter.write(`${JSON.stringify(result, null, 2)}\n`);
145
+ return result;
146
+ }
147
+
148
+ if (keyMinted)
149
+ prompter.write(`${done(`Minted a key for ${name} (${sandbox.id})`)}\n`);
150
+ prompter.write(
151
+ `${done(`Pinned ${SANDBOX_PIN_NAME}=${sandbox.id} in ${envPath}`)}\n`,
152
+ );
153
+ prompter.write(
154
+ ` Every command now targets ${name}. atmn sandbox use --clear returns to the main sandbox.\n`,
155
+ );
156
+ return result;
157
+ };
@@ -0,0 +1,27 @@
1
+ import { AutumnApiError } from "../../generated/client";
2
+
3
+ const SCOPE_REFUSAL = "Insufficient scopes";
4
+
5
+ export const SANDBOX_LOGIN_HINT =
6
+ "Your key can't manage sandboxes: it was minted before atmn asked for platform:write. Run atmn login again to mint one that can.";
7
+
8
+ export const SETTINGS_LOGIN_HINT =
9
+ "Your key can't write settings: it was minted before atmn asked for organisation:write. Run atmn login again to mint one that can, or drop the settings block.";
10
+
11
+ /** A key minted by an older login fails here first; say what to do, not just which scope is missing. */
12
+ export const withSandboxScopeHint = ({ error }: { error: unknown }): unknown =>
13
+ withScopeHint({ error, hint: SANDBOX_LOGIN_HINT });
14
+
15
+ export const withSettingsScopeHint = ({ error }: { error: unknown }): unknown =>
16
+ withScopeHint({ error, hint: SETTINGS_LOGIN_HINT });
17
+
18
+ const withScopeHint = ({
19
+ error,
20
+ hint,
21
+ }: {
22
+ error: unknown;
23
+ hint: string;
24
+ }): unknown =>
25
+ error instanceof AutumnApiError && error.message.includes(SCOPE_REFUSAL)
26
+ ? new Error(hint, { cause: error })
27
+ : error;
@@ -0,0 +1,246 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import chalk from "chalk";
12
+ import {
13
+ type BundledSkill,
14
+ SKILLS,
15
+ SKILLS_VERSION,
16
+ } from "../../generated/skills";
17
+ import { done, hint, same, type WriteLine } from "../../prompt/prompt";
18
+
19
+ /** The folder under the config where the canonical copies live. */
20
+ export const SKILLS_DIR_NAME = "skills";
21
+
22
+ const FRONTMATTER_VERSION = /^version:\s*(.+)$/m;
23
+
24
+ const findSkill = ({ name }: { name: string }): BundledSkill => {
25
+ const skill = SKILLS.find((candidate) => candidate.name === name);
26
+ if (skill === undefined)
27
+ throw new Error(
28
+ `No skill named ${JSON.stringify(name)}. Bundled: ${SKILLS.map((candidate) => candidate.name).join(", ")}.`,
29
+ );
30
+ return skill;
31
+ };
32
+
33
+ export const renderSkillsList = (): string => {
34
+ const width = Math.max(...SKILLS.map((skill) => skill.name.length));
35
+ const rows = SKILLS.map(
36
+ (skill) =>
37
+ `${skill.name.padEnd(width)} ${skill.description} ${chalk.dim(`(${skill.references.length} refs)`)}`,
38
+ );
39
+ return [
40
+ ...rows,
41
+ "",
42
+ `${chalk.dim("atmn skills <name>")} print a skill`,
43
+ `${chalk.dim("atmn skills <name> --ref <path>")} print one of its references`,
44
+ `${chalk.dim("atmn skills install [--dir d]")} write them next to your config`,
45
+ `${chalk.dim("atmn skills update [--dir d]")} bring an older install up to v${SKILLS_VERSION}`,
46
+ ].join("\n");
47
+ };
48
+
49
+ /** Raw to stdout, so `atmn skills catalog | …` is the whole integration. */
50
+ export const printSkill = ({
51
+ name,
52
+ ref,
53
+ json = false,
54
+ write,
55
+ }: {
56
+ name: string;
57
+ ref?: string;
58
+ json?: boolean;
59
+ write: WriteLine;
60
+ }): void => {
61
+ const skill = findSkill({ name });
62
+ if (json) {
63
+ write(`${JSON.stringify(skill, null, 2)}\n`);
64
+ return;
65
+ }
66
+ if (ref !== undefined) {
67
+ const reference = skill.references.find((entry) => entry.path === ref);
68
+ if (reference === undefined)
69
+ throw new Error(
70
+ `${skill.name} has no reference ${JSON.stringify(ref)}. Available: ${skill.references.map((entry) => entry.path).join(", ")}.`,
71
+ );
72
+ write(`${reference.contents}\n`);
73
+ return;
74
+ }
75
+ write(`${skill.markdown}\n`);
76
+ };
77
+
78
+ const writeSkill = ({ dir, skill }: { dir: string; skill: BundledSkill }) => {
79
+ const skillDir = join(dir, skill.name);
80
+ mkdirSync(skillDir, { recursive: true });
81
+ writeFileSync(join(skillDir, "SKILL.md"), skill.markdown, "utf8");
82
+ for (const reference of skill.references) {
83
+ const path = join(skillDir, reference.path);
84
+ mkdirSync(dirname(path), { recursive: true });
85
+ writeFileSync(path, reference.contents, "utf8");
86
+ }
87
+ };
88
+
89
+ export const installSkills = ({
90
+ dir,
91
+ write,
92
+ }: {
93
+ dir: string;
94
+ write: WriteLine;
95
+ }): { written: string[] } => {
96
+ for (const skill of SKILLS) writeSkill({ dir, skill });
97
+ write(
98
+ `${done(`Wrote ${SKILLS.length} skills to ${dir} (v${SKILLS_VERSION})`)}\n`,
99
+ );
100
+ write(`${hint(`npx skills add ${dir} -y`)} link them into your agent\n`);
101
+ return { written: SKILLS.map((skill) => skill.name) };
102
+ };
103
+
104
+ /** The fan-out to agent folders is `npx skills`' job: it knows the agents, we don't. */
105
+ export const linkSkills = async ({
106
+ dir,
107
+ write,
108
+ spawn = (args) =>
109
+ spawnSync(args[0] ?? "npx", args.slice(1), { stdio: "inherit" }).status ??
110
+ 1,
111
+ }: {
112
+ dir: string;
113
+ write: WriteLine;
114
+ spawn?: (args: string[]) => number;
115
+ }): Promise<boolean> => {
116
+ const exitCode = spawn(["npx", "skills", "add", dir, "--all"]);
117
+ if (exitCode === 0) return true;
118
+ write(
119
+ `${hint(`npx skills add ${dir} --all`)} did not complete; run it yourself when you are ready.\n`,
120
+ );
121
+ return false;
122
+ };
123
+
124
+ export type SkillStatus = {
125
+ name: string;
126
+ /** The version the installed SKILL.md states; null when not installed. */
127
+ installed: string | null;
128
+ bundled: string;
129
+ };
130
+
131
+ /** A skill folder may be a symlink into the canonical copy: follow it, so
132
+ * rewriting it updates every agent dir that links there at once. */
133
+ const realSkillDir = ({
134
+ dir,
135
+ name,
136
+ }: {
137
+ dir: string;
138
+ name: string;
139
+ }): string | null => {
140
+ const path = join(dir, name);
141
+ if (!existsSync(path)) return null;
142
+ return lstatSync(path).isSymbolicLink() ? realpathSync(path) : path;
143
+ };
144
+
145
+ const installedVersion = ({
146
+ skillDir,
147
+ }: {
148
+ skillDir: string;
149
+ }): string | null => {
150
+ const markdownPath = join(skillDir, "SKILL.md");
151
+ if (!existsSync(markdownPath)) return null;
152
+ const match = FRONTMATTER_VERSION.exec(readFileSync(markdownPath, "utf8"));
153
+ return match?.[1]?.trim() ?? "unknown";
154
+ };
155
+
156
+ /** SemVer ordering: numeric core, then prerelease identifiers (a release beats any prerelease). */
157
+ const compareVersions = (a: string, b: string): number => {
158
+ const parse = (v: string) => {
159
+ const version = v.split("+", 1)[0] ?? "";
160
+ const dash = version.indexOf("-");
161
+ const core = dash === -1 ? version : version.slice(0, dash);
162
+ const pre = dash === -1 ? null : version.slice(dash + 1).split(".");
163
+ return {
164
+ nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0),
165
+ pre,
166
+ };
167
+ };
168
+ const x = parse(a);
169
+ const y = parse(b);
170
+ for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) {
171
+ const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
172
+ if (d !== 0) return d;
173
+ }
174
+ if (x.pre === null && y.pre === null) return 0;
175
+ if (x.pre === null) return 1;
176
+ if (y.pre === null) return -1;
177
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
178
+ const p = x.pre[i];
179
+ const q = y.pre[i];
180
+ if (p === undefined) return -1;
181
+ if (q === undefined) return 1;
182
+ const pn = /^\d+$/.test(p) ? Number(p) : null;
183
+ const qn = /^\d+$/.test(q) ? Number(q) : null;
184
+ if (pn !== null && qn !== null) {
185
+ if (pn !== qn) return pn - qn;
186
+ } else if (pn !== null) return -1;
187
+ else if (qn !== null) return 1;
188
+ else if (p !== q) return p < q ? -1 : 1;
189
+ }
190
+ return 0;
191
+ };
192
+
193
+ /** Older than the bundle: the only case update touches. Unknown reads as older. */
194
+ const isStale = (installed: string | null): boolean =>
195
+ installed !== null &&
196
+ (installed === "unknown" || compareVersions(installed, SKILLS_VERSION) < 0);
197
+
198
+ export const skillsStatus = ({ dir }: { dir: string }): SkillStatus[] =>
199
+ SKILLS.map((skill) => {
200
+ const skillDir = realSkillDir({ dir, name: skill.name });
201
+ return {
202
+ name: skill.name,
203
+ installed: skillDir === null ? null : installedVersion({ skillDir }),
204
+ bundled: SKILLS_VERSION,
205
+ };
206
+ });
207
+
208
+ /** One dim line for push/pull when an installed skill is older than the CLI; null when nothing is. */
209
+ export const staleSkillsHint = ({ dir }: { dir: string }): string | null => {
210
+ const stale = skillsStatus({ dir }).filter((entry) =>
211
+ isStale(entry.installed),
212
+ );
213
+ if (stale.length === 0) return null;
214
+ const versions = [...new Set(stale.map((entry) => entry.installed))].join(
215
+ ", ",
216
+ );
217
+ return chalk.dim(
218
+ `skills are at v${versions}, the CLI is v${SKILLS_VERSION}: atmn skills update`,
219
+ );
220
+ };
221
+
222
+ export const updateSkills = ({
223
+ dir,
224
+ write,
225
+ }: {
226
+ dir: string;
227
+ write: WriteLine;
228
+ }): { updated: string[] } => {
229
+ const updated: string[] = [];
230
+ for (const skill of SKILLS) {
231
+ const skillDir = realSkillDir({ dir, name: skill.name });
232
+ const installed = skillDir === null ? null : installedVersion({ skillDir });
233
+ if (installed !== null && !isStale(installed)) {
234
+ write(
235
+ `${same(`${skill.name} ${installed === SKILLS_VERSION ? "up to date" : `${installed} is newer than this CLI, kept`}`)}\n`,
236
+ );
237
+ continue;
238
+ }
239
+ writeSkill({ dir: skillDir === null ? dir : dirname(skillDir), skill });
240
+ updated.push(skill.name);
241
+ write(
242
+ `${done(`${skill.name} ${installed === null ? "installed" : `${installed} → ${SKILLS_VERSION}`}`)}\n`,
243
+ );
244
+ }
245
+ return { updated };
246
+ };
@@ -0,0 +1,26 @@
1
+ import { tryOpenBrowser } from "./browser/tryOpenBrowser";
2
+ import type { BrowserOpener } from "./types/browserOpener";
3
+
4
+ /**
5
+ * The URL is printed whether or not a browser opened, so it can be finished on
6
+ * a phone or a laptop when the CLI is running over SSH or in a container.
7
+ */
8
+ export const announceAuthorizationUrl = async ({
9
+ url,
10
+ write,
11
+ openBrowser,
12
+ }: {
13
+ url: string;
14
+ write: (text: string) => void;
15
+ openBrowser: BrowserOpener;
16
+ }): Promise<void> => {
17
+ write(`\nVisit this URL to authenticate:\n\n ${url}\n\n`);
18
+
19
+ const opened = await tryOpenBrowser({ url, openBrowser });
20
+
21
+ write(
22
+ opened
23
+ ? "Opened your browser. Waiting for authorization...\n"
24
+ : "No browser could be opened here — open the URL above on any machine.\nWaiting for authorization...\n",
25
+ );
26
+ };
@@ -0,0 +1,7 @@
1
+ import open from "open";
2
+ import type { BrowserOpener } from "../types/browserOpener";
3
+ import { watchLauncher } from "./watchLauncher";
4
+
5
+ export const openSystemBrowser: BrowserOpener = async ({ url }) => {
6
+ await watchLauncher({ launcher: await open(url) });
7
+ };
@@ -0,0 +1,17 @@
1
+ import type { BrowserOpener } from "../types/browserOpener";
2
+
3
+ /** Never throws: a machine with no browser is a supported way to log in. */
4
+ export const tryOpenBrowser = async ({
5
+ url,
6
+ openBrowser,
7
+ }: {
8
+ url: string;
9
+ openBrowser: BrowserOpener;
10
+ }): Promise<boolean> => {
11
+ try {
12
+ await openBrowser({ url });
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ };
@@ -0,0 +1,46 @@
1
+ /** Anything with node's `once`; a spawned `ChildProcess` satisfies it. */
2
+ export type Launcher = {
3
+ once(event: string, listener: (...args: unknown[]) => void): unknown;
4
+ };
5
+
6
+ /**
7
+ * How long to keep watching before treating a still-running launcher as a
8
+ * success — some openers stay resident for the life of the browser.
9
+ */
10
+ export const LAUNCHER_SETTLE_MS = 1500;
11
+
12
+ /**
13
+ * A launcher that never spawned (no xdg-open) or exited non-zero (no display,
14
+ * no default browser) opened nothing. This, not a TTY check, is what tells us
15
+ * the environment is headless.
16
+ */
17
+ export const watchLauncher = ({
18
+ launcher,
19
+ settleMs = LAUNCHER_SETTLE_MS,
20
+ }: {
21
+ launcher: Launcher;
22
+ settleMs?: number;
23
+ }): Promise<void> =>
24
+ new Promise((resolve, reject) => {
25
+ const settled = setTimeout(resolve, settleMs);
26
+
27
+ launcher.once("error", (...args: unknown[]) => {
28
+ clearTimeout(settled);
29
+ const [cause] = args;
30
+ reject(
31
+ cause instanceof Error
32
+ ? cause
33
+ : new Error("Browser launcher failed to start"),
34
+ );
35
+ });
36
+
37
+ launcher.once("close", (...args: unknown[]) => {
38
+ clearTimeout(settled);
39
+ const [code] = args;
40
+ if (typeof code === "number" && code !== 0) {
41
+ reject(new Error(`Browser launcher exited with code ${code}`));
42
+ return;
43
+ }
44
+ resolve();
45
+ });
46
+ });
@@ -0,0 +1,36 @@
1
+ import { CodeChallengeMethod, OAuth2Client } from "arctic";
2
+ import { getAuthorizationEndpoint, getOAuthRedirectUri } from "./oauthConfig";
3
+
4
+ export const createOAuthClient = ({
5
+ clientId,
6
+ port,
7
+ }: {
8
+ clientId: string;
9
+ port: number;
10
+ }): OAuth2Client =>
11
+ new OAuth2Client(clientId, null, getOAuthRedirectUri({ port }));
12
+
13
+ export const buildAuthorizationUrl = ({
14
+ client,
15
+ backendUrl,
16
+ scopes,
17
+ state,
18
+ codeVerifier,
19
+ }: {
20
+ client: OAuth2Client;
21
+ backendUrl: string;
22
+ scopes: readonly string[];
23
+ state: string;
24
+ codeVerifier: string;
25
+ }): URL => {
26
+ const url = client.createAuthorizationURLWithPKCE(
27
+ getAuthorizationEndpoint({ backendUrl }),
28
+ state,
29
+ CodeChallengeMethod.S256,
30
+ codeVerifier,
31
+ [...scopes],
32
+ );
33
+ // Always show the org picker; a silent grant would pin the wrong org.
34
+ url.searchParams.set("prompt", "consent");
35
+ return url;
36
+ };
@@ -0,0 +1,126 @@
1
+ /** Ported from v2 so the browser half of login looks unchanged. */
2
+ const BASE_STYLES = `
3
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
4
+
5
+ * { box-sizing: border-box; margin: 0; padding: 0; }
6
+
7
+ body {
8
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
9
+ min-height: 100vh;
10
+ display: flex;
11
+ align-items: center;
12
+ justify-content: center;
13
+ background: #fafaf9;
14
+ color: #121212;
15
+ -webkit-font-smoothing: antialiased;
16
+ -moz-osx-font-smoothing: grayscale;
17
+ }
18
+
19
+ .container { text-align: center; padding: 3rem 2rem; max-width: 400px; }
20
+
21
+ .icon {
22
+ width: 64px;
23
+ height: 64px;
24
+ border-radius: 16px;
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ margin: 0 auto 1.5rem;
29
+ }
30
+
31
+ .icon-success {
32
+ background: linear-gradient(135deg, #f3e8ff 0%, #ede1ff 100%);
33
+ border: 2px solid #c4b5fd;
34
+ color: #8838ff;
35
+ }
36
+
37
+ .icon-error {
38
+ background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
39
+ border: 2px solid #fca5a5;
40
+ color: #dc2626;
41
+ }
42
+
43
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 0.5rem; letter-spacing: -0.02em; }
44
+ .error h1 { color: #dc2626; }
45
+
46
+ .description { font-size: 14px; color: #666; line-height: 1.5; margin-bottom: 1.5rem; }
47
+
48
+ .hint {
49
+ font-size: 13px;
50
+ color: #888;
51
+ padding: 0.75rem 1rem;
52
+ background: #f5f5f4;
53
+ border-radius: 8px;
54
+ border: 1px solid #e5e5e5;
55
+ }
56
+
57
+ @media (prefers-color-scheme: dark) {
58
+ body { background: #161616; color: #ddd; }
59
+ .icon-success { background: linear-gradient(135deg, #2d1f4e 0%, #3d2a5e 100%); border-color: #6b46c1; color: #a855f7; }
60
+ .icon-error { background: linear-gradient(135deg, #4a1a1a 0%, #5c2020 100%); border-color: #dc2626; color: #f87171; }
61
+ .error h1 { color: #f87171; }
62
+ .description { color: #999; }
63
+ .hint { background: #1d1d1d; border-color: #2c2c2c; }
64
+ }
65
+ `;
66
+
67
+ const SUCCESS_ICON = `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
68
+
69
+ const ERROR_ICON = `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
70
+
71
+ const renderPage = ({
72
+ variant,
73
+ icon,
74
+ title,
75
+ description,
76
+ hint,
77
+ }: {
78
+ variant: "success" | "error";
79
+ icon: string;
80
+ title: string;
81
+ description: string;
82
+ hint: string;
83
+ }): string => `<!DOCTYPE html>
84
+ <html lang="en">
85
+ <head>
86
+ <meta charset="UTF-8">
87
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
88
+ <title>${title} - Autumn</title>
89
+ <style>${BASE_STYLES}</style>
90
+ </head>
91
+ <body>
92
+ <div class="container ${variant}">
93
+ <div class="icon icon-${variant}">${icon}</div>
94
+ <h1>${title}</h1>
95
+ <p class="description">${description}</p>
96
+ <p class="hint">${hint}</p>
97
+ </div>
98
+ </body>
99
+ </html>`;
100
+
101
+ /** The error text comes from the authorization server, so it must be escaped. */
102
+ const escapeHtml = ({ text }: { text: string }): string =>
103
+ text
104
+ .replaceAll("&", "&amp;")
105
+ .replaceAll("<", "&lt;")
106
+ .replaceAll(">", "&gt;")
107
+ .replaceAll('"', "&quot;")
108
+ .replaceAll("'", "&#039;");
109
+
110
+ export const renderSuccessPage = (): string =>
111
+ renderPage({
112
+ variant: "success",
113
+ icon: SUCCESS_ICON,
114
+ title: "Authorization Successful",
115
+ description: "Your CLI has been authenticated successfully.",
116
+ hint: "You can close this window and return to your terminal.",
117
+ });
118
+
119
+ export const renderErrorPage = ({ message }: { message: string }): string =>
120
+ renderPage({
121
+ variant: "error",
122
+ icon: ERROR_ICON,
123
+ title: "Authorization Failed",
124
+ description: escapeHtml({ text: message }),
125
+ hint: "Please close this window and try again in your terminal.",
126
+ });