assistant-ui 0.0.116 → 0.0.118

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 (127) hide show
  1. package/dist/codemods/utils/createTransformer.d.ts +13 -16
  2. package/dist/codemods/utils/createTransformer.d.ts.map +1 -1
  3. package/dist/codemods/utils/resolveBinding.d.ts +3 -0
  4. package/dist/codemods/utils/resolveBinding.d.ts.map +1 -0
  5. package/dist/codemods/utils/resolveBinding.js +78 -0
  6. package/dist/codemods/utils/resolveBinding.js.map +1 -0
  7. package/dist/codemods/v0-11/content-part-to-message-part.d.ts +1 -3
  8. package/dist/codemods/v0-11/content-part-to-message-part.d.ts.map +1 -1
  9. package/dist/codemods/v0-12/assistant-api-to-aui.d.ts +1 -3
  10. package/dist/codemods/v0-12/assistant-api-to-aui.d.ts.map +1 -1
  11. package/dist/codemods/v0-12/assistant-api-to-aui.js +51 -99
  12. package/dist/codemods/v0-12/assistant-api-to-aui.js.map +1 -1
  13. package/dist/codemods/v0-12/event-names-to-camelcase.d.ts +1 -3
  14. package/dist/codemods/v0-12/event-names-to-camelcase.d.ts.map +1 -1
  15. package/dist/codemods/v0-12/primitive-if-to-aui-if.d.ts +1 -3
  16. package/dist/codemods/v0-12/primitive-if-to-aui-if.d.ts.map +1 -1
  17. package/dist/codemods/v0-15/aui-accessor-calls-to-properties.d.ts +1 -3
  18. package/dist/codemods/v0-15/aui-accessor-calls-to-properties.d.ts.map +1 -1
  19. package/dist/codemods/v0-15/aui-accessor-calls-to-properties.js +33 -5
  20. package/dist/codemods/v0-15/aui-accessor-calls-to-properties.js.map +1 -1
  21. package/dist/codemods/v0-8/ui-package-split.d.ts +1 -3
  22. package/dist/codemods/v0-8/ui-package-split.d.ts.map +1 -1
  23. package/dist/codemods/v0-9/edge-package-split.d.ts +1 -3
  24. package/dist/codemods/v0-9/edge-package-split.d.ts.map +1 -1
  25. package/dist/commands/add.d.ts +13 -16
  26. package/dist/commands/add.d.ts.map +1 -1
  27. package/dist/commands/add.js +3 -3
  28. package/dist/commands/agent.d.ts +1 -4
  29. package/dist/commands/agent.d.ts.map +1 -1
  30. package/dist/commands/agent.js +16 -13
  31. package/dist/commands/agent.js.map +1 -1
  32. package/dist/commands/create.d.ts +43 -39
  33. package/dist/commands/create.d.ts.map +1 -1
  34. package/dist/commands/create.js +40 -26
  35. package/dist/commands/create.js.map +1 -1
  36. package/dist/commands/doctor.d.ts +17 -20
  37. package/dist/commands/doctor.d.ts.map +1 -1
  38. package/dist/commands/info.d.ts +4 -6
  39. package/dist/commands/info.d.ts.map +1 -1
  40. package/dist/commands/info.js +3 -3
  41. package/dist/commands/init.d.ts +8 -10
  42. package/dist/commands/init.d.ts.map +1 -1
  43. package/dist/commands/init.js +3 -4
  44. package/dist/commands/init.js.map +1 -1
  45. package/dist/commands/mcp.d.ts +1 -4
  46. package/dist/commands/mcp.d.ts.map +1 -1
  47. package/dist/commands/mcp.js +2 -2
  48. package/dist/commands/update.d.ts +1 -4
  49. package/dist/commands/update.d.ts.map +1 -1
  50. package/dist/commands/update.js +1 -1
  51. package/dist/commands/upgrade.d.ts +7 -10
  52. package/dist/commands/upgrade.d.ts.map +1 -1
  53. package/dist/index.d.ts +3 -1
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/lib/agent-skill.d.ts +11 -10
  56. package/dist/lib/agent-skill.d.ts.map +1 -1
  57. package/dist/lib/agent-skill.js +78 -2
  58. package/dist/lib/agent-skill.js.map +1 -1
  59. package/dist/lib/create-project.d.ts +33 -32
  60. package/dist/lib/create-project.d.ts.map +1 -1
  61. package/dist/lib/create-project.js +91 -5
  62. package/dist/lib/create-project.js.map +1 -1
  63. package/dist/lib/handle-cli-error.d.ts +1 -4
  64. package/dist/lib/handle-cli-error.d.ts.map +1 -1
  65. package/dist/lib/install-ai-sdk-lib.d.ts +1 -4
  66. package/dist/lib/install-ai-sdk-lib.d.ts.map +1 -1
  67. package/dist/lib/install-edge-lib.d.ts +1 -4
  68. package/dist/lib/install-edge-lib.d.ts.map +1 -1
  69. package/dist/lib/run-spawn.d.ts +17 -19
  70. package/dist/lib/run-spawn.d.ts.map +1 -1
  71. package/dist/lib/run-spawn.js +25 -10
  72. package/dist/lib/run-spawn.js.map +1 -1
  73. package/dist/lib/transform-options.d.ts +5 -8
  74. package/dist/lib/transform-options.d.ts.map +1 -1
  75. package/dist/lib/transform.d.ts +13 -16
  76. package/dist/lib/transform.d.ts.map +1 -1
  77. package/dist/lib/transform.js +19 -35
  78. package/dist/lib/transform.js.map +1 -1
  79. package/dist/lib/upgrade.d.ts +3 -6
  80. package/dist/lib/upgrade.d.ts.map +1 -1
  81. package/dist/lib/upgrade.js +6 -11
  82. package/dist/lib/upgrade.js.map +1 -1
  83. package/dist/lib/utils/config.d.ts +18 -21
  84. package/dist/lib/utils/config.d.ts.map +1 -1
  85. package/dist/lib/utils/file-scanner.d.ts +12 -15
  86. package/dist/lib/utils/file-scanner.d.ts.map +1 -1
  87. package/dist/lib/utils/logger.d.ts +7 -10
  88. package/dist/lib/utils/logger.d.ts.map +1 -1
  89. package/dist/lib/utils/package-installer.d.ts +7 -10
  90. package/dist/lib/utils/package-installer.d.ts.map +1 -1
  91. package/dist/lib/utils/package-json.d.ts +1 -4
  92. package/dist/lib/utils/package-json.d.ts.map +1 -1
  93. package/dist/lib/utils/package-manager.d.ts +9 -12
  94. package/dist/lib/utils/package-manager.d.ts.map +1 -1
  95. package/dist/lib/utils/package-manager.js +13 -2
  96. package/dist/lib/utils/package-manager.js.map +1 -1
  97. package/dist/lib/utils/registry.d.ts +5 -8
  98. package/dist/lib/utils/registry.d.ts.map +1 -1
  99. package/dist/lib/utils/workspace.d.ts +2 -5
  100. package/dist/lib/utils/workspace.d.ts.map +1 -1
  101. package/dist/program.d.ts +1 -4
  102. package/dist/program.d.ts.map +1 -1
  103. package/dist/program.js +6 -6
  104. package/dist/run.d.ts +1 -4
  105. package/dist/run.d.ts.map +1 -1
  106. package/package.json +8 -8
  107. package/src/codemods/utils/resolveBinding.test.ts +107 -0
  108. package/src/codemods/utils/resolveBinding.ts +130 -0
  109. package/src/codemods/v0-12/__tests__/assistant-api-to-aui.test.ts +256 -0
  110. package/src/codemods/v0-12/assistant-api-to-aui.ts +125 -225
  111. package/src/codemods/v0-15/__tests__/aui-accessor-calls-to-properties.test.ts +101 -0
  112. package/src/codemods/v0-15/aui-accessor-calls-to-properties.ts +60 -5
  113. package/src/commands/agent.ts +15 -22
  114. package/src/commands/create.ts +53 -20
  115. package/src/commands/init.ts +1 -2
  116. package/src/lib/agent-skill.ts +103 -1
  117. package/src/lib/create-project.ts +99 -4
  118. package/src/lib/run-spawn.test.ts +47 -0
  119. package/src/lib/run-spawn.ts +29 -17
  120. package/src/lib/transform-files.test.ts +71 -0
  121. package/src/lib/transform.test.ts +69 -1
  122. package/src/lib/transform.ts +23 -38
  123. package/src/lib/upgrade.ts +9 -13
  124. package/src/lib/utils/package-manager.ts +24 -1
  125. package/plugin/.claude-plugin/plugin.json +0 -8
  126. package/plugin/skills/assistant-ui/SKILL.md +0 -199
  127. package/plugin/skills/assistant-ui/shipables.json +0 -22
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import * as p from "@clack/prompts";
6
6
  import { logger } from "../lib/utils/logger";
7
7
  import {
8
+ cleanupPendingProjectDownloads,
8
9
  dlxCommand,
9
10
  downloadProject,
10
11
  resolveLatestReleaseRef,
@@ -382,10 +383,30 @@ export function resolveCreateProjectDirectory(params: {
382
383
  const { projectDirectory, stdinIsTTY = process.stdin.isTTY } = params;
383
384
 
384
385
  if (projectDirectory) return projectDirectory;
385
- if (!stdinIsTTY) return "my-aui-app";
386
+ if (!stdinIsTTY) return DEFAULT_PROJECT_DIRECTORY;
386
387
  return undefined;
387
388
  }
388
389
 
390
+ export const DEFAULT_PROJECT_DIRECTORY = "my-aui-app";
391
+
392
+ export const projectNamePromptOptions = {
393
+ message: "Project name:",
394
+ placeholder: DEFAULT_PROJECT_DIRECTORY,
395
+ defaultValue: DEFAULT_PROJECT_DIRECTORY,
396
+ validate: (value?: string) => {
397
+ // Enter on an untouched prompt is how clack accepts `defaultValue`, and it
398
+ // validates before finalize substitutes it, so an empty value has to pass
399
+ // here or the default is unreachable.
400
+ if (value === undefined || value === "") return undefined;
401
+ const name = value.trim();
402
+ if (!name) return "Project name cannot be empty";
403
+ if (name === "." || name === "..") return "Project name cannot be . or ..";
404
+ if (name.includes("/") || name.includes("\\"))
405
+ return "Project name cannot contain path separators";
406
+ return undefined;
407
+ },
408
+ };
409
+
389
410
  export function resolveProjectDirectoryGuidance(params: {
390
411
  absoluteProjectDir: string;
391
412
  cwd?: string;
@@ -520,6 +541,12 @@ export const create = new Command()
520
541
  .option("--skip-install", "skip installing packages")
521
542
  .option("--skills", "add assistant-ui agent skills for AI coding assistants")
522
543
  .option("--no-skills", "skip adding assistant-ui agent skills")
544
+ .addOption(
545
+ new Option(
546
+ "--cwd <cwd>",
547
+ "the working directory. defaults to the current directory.",
548
+ ).hideHelp(),
549
+ )
523
550
  .addOption(
524
551
  new Option(
525
552
  "--debug-source-root <path>",
@@ -551,20 +578,7 @@ export const create = new Command()
551
578
  });
552
579
 
553
580
  if (!resolvedProjectDirectory) {
554
- const result = await p.text({
555
- message: "Project name:",
556
- placeholder: "my-aui-app",
557
- defaultValue: "my-aui-app",
558
- validate: (value?: string) => {
559
- const name = (value ?? "").trim();
560
- if (!name) return "Project name cannot be empty";
561
- if (name === "." || name === "..")
562
- return "Project name cannot be . or ..";
563
- if (name.includes("/") || name.includes("\\"))
564
- return "Project name cannot contain path separators";
565
- return undefined;
566
- },
567
- });
581
+ const result = await p.text(projectNamePromptOptions);
568
582
 
569
583
  if (p.isCancel(result)) {
570
584
  p.cancel("Project creation cancelled.");
@@ -575,9 +589,13 @@ export const create = new Command()
575
589
  }
576
590
 
577
591
  // Check directory
578
- const absoluteProjectDir = path.resolve(resolvedProjectDirectory);
592
+ const absoluteProjectDir = path.resolve(
593
+ opts.cwd ?? process.cwd(),
594
+ resolvedProjectDirectory,
595
+ );
579
596
  const { display: displayProjectDir, cdCommand } =
580
597
  resolveProjectDirectoryGuidance({ absoluteProjectDir });
598
+ let projectDirExisted = true;
581
599
  try {
582
600
  const files = fs.readdirSync(absoluteProjectDir);
583
601
  if (files.length > 0) {
@@ -591,6 +609,7 @@ export const create = new Command()
591
609
  err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined;
592
610
  if (code === "ENOENT") {
593
611
  // Directory doesn't exist — good, proceed
612
+ projectDirExisted = false;
594
613
  } else if (code === "ENOTDIR") {
595
614
  logger.error(
596
615
  `${displayProjectDir} already exists and is not a directory`,
@@ -638,11 +657,24 @@ export const create = new Command()
638
657
  );
639
658
 
640
659
  // Clean up partial project directory on unexpected exit (e.g. Ctrl+C)
660
+ const resetProjectDir = () => {
661
+ if (!projectDirExisted) {
662
+ fs.rmSync(absoluteProjectDir, { recursive: true, force: true });
663
+ return;
664
+ }
665
+ if (!fs.existsSync(absoluteProjectDir)) return;
666
+ for (const entry of fs.readdirSync(absoluteProjectDir)) {
667
+ fs.rmSync(path.join(absoluteProjectDir, entry), {
668
+ recursive: true,
669
+ force: true,
670
+ });
671
+ }
672
+ };
641
673
  let cleanupArmed = true;
642
674
  const cleanupOnExit = () => {
643
675
  if (!cleanupArmed) return;
644
676
  cleanupArmed = false;
645
- fs.rmSync(absoluteProjectDir, { recursive: true, force: true });
677
+ resetProjectDir();
646
678
  };
647
679
  const disarmCleanup = () => {
648
680
  cleanupArmed = false;
@@ -651,10 +683,11 @@ export const create = new Command()
651
683
  process.removeListener("SIGTERM", cleanupOnSignal);
652
684
  };
653
685
  // Node emits no "exit" when a signal kills the process. An in-flight
654
- // runSpawn forwards the signal itself, so the directory is removed on the
655
- // error path once the child is reaped rather than while it is still writing.
686
+ // runSpawn forwards the signal itself, so cleanup runs on the error path
687
+ // once the child is reaped rather than while it is still writing.
656
688
  const cleanupOnSignal = (signal: NodeJS.Signals) => {
657
689
  if (hasActiveSpawn()) return;
690
+ cleanupPendingProjectDownloads();
658
691
  cleanupOnExit();
659
692
  disarmCleanup();
660
693
  process.kill(process.pid, signal);
@@ -696,7 +729,7 @@ export const create = new Command()
696
729
  ref &&
697
730
  !fs.existsSync(path.join(absoluteProjectDir, "package.json"))
698
731
  ) {
699
- fs.rmSync(absoluteProjectDir, { recursive: true, force: true });
732
+ resetProjectDir();
700
733
  logger.warn(
701
734
  "Template not found at release tag, downloading from HEAD",
702
735
  );
@@ -83,8 +83,7 @@ export const init = new Command()
83
83
  logger.break();
84
84
  }
85
85
 
86
- const createArgs: string[] = [];
87
- if (projectDirectory) createArgs.push(targetDir);
86
+ const createArgs = projectDirectory ? [targetDir] : ["--cwd", cwd];
88
87
  if (presetUrl) createArgs.push("--preset", presetUrl);
89
88
  if (opts.useNpm) createArgs.push("--use-npm");
90
89
  if (opts.usePnpm) createArgs.push("--use-pnpm");
@@ -1,7 +1,17 @@
1
- import { dlxCommand } from "./create-project";
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { downloadTemplate } from "giget";
5
+ import {
6
+ DOWNLOAD_TIMEOUT_MS,
7
+ dlxCommand,
8
+ resolveGitHubAuthToken,
9
+ } from "./create-project";
2
10
  import { type PackageManagerName } from "./utils/package-manager";
3
11
 
4
12
  export const SKILLS_PACKAGE = "assistant-ui/skills";
13
+ export const SKILLS_COMMIT = "66befc4468ce0691c9203442d70cbbdcb5562ea1";
14
+ export const SKILLS_PLUGIN_SOURCE = `gh:${SKILLS_PACKAGE}/assistant-ui#${SKILLS_COMMIT}`;
5
15
 
6
16
  export function resolveSkillsInstall(params: {
7
17
  skills?: boolean;
@@ -27,3 +37,95 @@ export function buildSkillsAddCommand(
27
37
 
28
38
  return [command, args];
29
39
  }
40
+
41
+ export function skillsPluginDir(): string {
42
+ const cacheHome =
43
+ process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
44
+ return path.join(cacheHome, "assistant-ui", "skills", SKILLS_COMMIT);
45
+ }
46
+
47
+ export async function ensureSkillsPlugin(): Promise<string> {
48
+ const dir = skillsPluginDir();
49
+ const marker = path.join(dir, ".claude-plugin", "plugin.json");
50
+ if (fs.existsSync(marker)) return dir;
51
+
52
+ const parent = path.dirname(dir);
53
+ await fs.promises.mkdir(parent, { recursive: true });
54
+ const staging = await fs.promises.mkdtemp(path.join(parent, ".staging-"));
55
+ // Node emits no "exit" when a signal kills the process, so the staging
56
+ // directory is removed on the signal and the signal re-raised. The
57
+ // listeners stay armed until the removal runs, which a download that
58
+ // outlives the timeout defers past the caller's own exit.
59
+ const removeStaging = () => {
60
+ process.removeListener("exit", removeStaging);
61
+ process.removeListener("SIGINT", removeStagingOnSignal);
62
+ process.removeListener("SIGTERM", removeStagingOnSignal);
63
+ fs.rmSync(staging, { recursive: true, force: true });
64
+ };
65
+ const removeStagingOnSignal = (signal: NodeJS.Signals) => {
66
+ removeStaging();
67
+ process.kill(process.pid, signal);
68
+ };
69
+ process.once("exit", removeStaging);
70
+ process.once("SIGINT", removeStagingOnSignal);
71
+ process.once("SIGTERM", removeStagingOnSignal);
72
+
73
+ // giget logs to console.debug whenever DEBUG is set, which the `debug`
74
+ // package does at module load for an unrelated namespace.
75
+ const origDebug = process.env.DEBUG;
76
+ delete process.env.DEBUG;
77
+ let timer: ReturnType<typeof setTimeout> | undefined;
78
+ let downloadSettled = false;
79
+ let download: Promise<unknown> | undefined;
80
+ try {
81
+ const authToken = resolveGitHubAuthToken();
82
+ download = downloadTemplate(SKILLS_PLUGIN_SOURCE, {
83
+ dir: staging,
84
+ preferOffline: true,
85
+ silent: true,
86
+ ...(authToken ? { auth: authToken } : {}),
87
+ }).finally(() => {
88
+ downloadSettled = true;
89
+ });
90
+ const timeout = new Promise<never>((_, reject) => {
91
+ timer = setTimeout(
92
+ () => reject(new Error("Download timed out.")),
93
+ DOWNLOAD_TIMEOUT_MS,
94
+ );
95
+ });
96
+ await Promise.race([download, timeout]).catch((error: unknown) => {
97
+ throw new Error(
98
+ "Could not fetch the assistant-ui skills from GitHub. Check your network, or set GITHUB_TOKEN if you are rate limited.",
99
+ { cause: error },
100
+ );
101
+ });
102
+ await publish(staging, dir, marker);
103
+ } finally {
104
+ clearTimeout(timer);
105
+ if (origDebug !== undefined) process.env.DEBUG = origDebug;
106
+ if (!download || downloadSettled) removeStaging();
107
+ else void download.then(removeStaging, removeStaging);
108
+ }
109
+
110
+ return dir;
111
+ }
112
+
113
+ // A rename fails when the target directory exists and is not empty. A
114
+ // concurrent invocation that published the same commit meanwhile wins and
115
+ // the staged copy is dropped; a directory without the marker is a stale
116
+ // partial cache and is replaced.
117
+ async function publish(
118
+ staging: string,
119
+ dir: string,
120
+ marker: string,
121
+ retry = true,
122
+ ): Promise<void> {
123
+ try {
124
+ await fs.promises.rename(staging, dir);
125
+ } catch (error) {
126
+ if (fs.existsSync(marker)) return;
127
+ if (!retry) throw error;
128
+ await fs.promises.rm(dir, { recursive: true, force: true });
129
+ await publish(staging, dir, marker, false);
130
+ }
131
+ }
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import { downloadTemplate } from "giget";
4
5
  import {
@@ -68,7 +69,7 @@ export function resolvePackageManager(opts: {
68
69
  return undefined;
69
70
  }
70
71
 
71
- function resolveGitHubAuthToken(): string | undefined {
72
+ export function resolveGitHubAuthToken(): string | undefined {
72
73
  const token =
73
74
  process.env.GIGET_AUTH ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
74
75
  const trimmed = token?.trim();
@@ -96,7 +97,16 @@ export async function resolveLatestReleaseRef(): Promise<string | undefined> {
96
97
  }
97
98
  }
98
99
 
99
- const DOWNLOAD_TIMEOUT_MS = 30_000;
100
+ export const DOWNLOAD_TIMEOUT_MS = 30_000;
101
+ const pendingDownloadCleanups = new Set<() => void>();
102
+
103
+ export function cleanupPendingProjectDownloads(): void {
104
+ for (const cleanup of pendingDownloadCleanups) {
105
+ pendingDownloadCleanups.delete(cleanup);
106
+ process.removeListener("exit", cleanup);
107
+ cleanup();
108
+ }
109
+ }
100
110
 
101
111
  export async function downloadProject(
102
112
  repoPath: string,
@@ -113,13 +123,74 @@ export async function downloadProject(
113
123
  // namespaces. Temporarily unsetting it targets the root cause.
114
124
  const origDebug = process.env.DEBUG;
115
125
  delete process.env.DEBUG;
126
+ let destinationCreated = false;
127
+ let stagingDir: string | undefined;
128
+ let downloadPromise: Promise<unknown> | undefined;
129
+ let downloadFinished = false;
130
+ let downloadCommitted = false;
131
+ let cleanupRequested = false;
132
+ const removeCleanupListeners = () => {
133
+ process.removeListener("exit", cleanupOnExit);
134
+ pendingDownloadCleanups.delete(cleanupOnExit);
135
+ };
136
+ const attemptSyncCleanup = (cleanup: () => void) => {
137
+ try {
138
+ cleanup();
139
+ } catch {
140
+ return;
141
+ }
142
+ };
143
+ const cleanupOnExit = () => {
144
+ cleanupRequested = true;
145
+ const currentStagingDir = stagingDir;
146
+ if (currentStagingDir) {
147
+ attemptSyncCleanup(() => {
148
+ fs.rmSync(currentStagingDir, { recursive: true, force: true });
149
+ });
150
+ }
151
+ if (destinationCreated && !downloadCommitted) {
152
+ attemptSyncCleanup(() => {
153
+ fs.rmdirSync(destDir);
154
+ });
155
+ }
156
+ };
157
+ const removeStagingDir = async () => {
158
+ if (stagingDir) {
159
+ await fs.promises
160
+ .rm(stagingDir, { recursive: true, force: true })
161
+ .catch(() => undefined);
162
+ }
163
+ removeCleanupListeners();
164
+ if (destinationCreated && !downloadCommitted) {
165
+ await fs.promises.rmdir(destDir).catch(() => undefined);
166
+ }
167
+ };
116
168
  try {
169
+ await fs.promises.mkdir(path.dirname(destDir), { recursive: true });
170
+ try {
171
+ stagingDir = await fs.promises.mkdtemp(
172
+ path.join(path.dirname(destDir), ".assistant-ui-download-"),
173
+ );
174
+ } catch (error) {
175
+ const code = (error as NodeJS.ErrnoException).code;
176
+ if (code !== "EACCES" && code !== "EPERM" && code !== "EROFS") {
177
+ throw error;
178
+ }
179
+ stagingDir = await fs.promises.mkdtemp(
180
+ path.join(os.tmpdir(), ".assistant-ui-download-"),
181
+ );
182
+ }
183
+ process.once("exit", cleanupOnExit);
184
+ pendingDownloadCleanups.add(cleanupOnExit);
185
+
117
186
  const authToken = resolveGitHubAuthToken();
118
- const downloadPromise = downloadTemplate(source, {
119
- dir: destDir,
187
+ downloadPromise = downloadTemplate(source, {
188
+ dir: stagingDir,
120
189
  force: true,
121
190
  silent: true,
122
191
  ...(authToken ? { auth: authToken } : {}),
192
+ }).finally(() => {
193
+ downloadFinished = true;
123
194
  });
124
195
 
125
196
  let timer: ReturnType<typeof setTimeout>;
@@ -137,10 +208,34 @@ export async function downloadProject(
137
208
 
138
209
  try {
139
210
  await Promise.race([downloadPromise, timeoutPromise]);
211
+ if (cleanupRequested) throw new Error("Download was interrupted.");
212
+ destinationCreated = !fs.existsSync(destDir);
213
+ await fs.promises.mkdir(destDir, { recursive: true });
214
+ for (const entry of await fs.promises.readdir(stagingDir)) {
215
+ const target = path.join(destDir, entry);
216
+ const sourceEntry = path.join(stagingDir, entry);
217
+ await fs.promises.rm(target, { recursive: true, force: true });
218
+ try {
219
+ await fs.promises.rename(sourceEntry, target);
220
+ } catch (error) {
221
+ if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
222
+ await fs.promises.cp(sourceEntry, target, {
223
+ recursive: true,
224
+ force: true,
225
+ });
226
+ await fs.promises.rm(sourceEntry, { recursive: true, force: true });
227
+ }
228
+ }
229
+ downloadCommitted = true;
140
230
  } finally {
141
231
  clearTimeout(timer!);
142
232
  }
143
233
  } finally {
234
+ if (!downloadPromise || downloadFinished) {
235
+ await removeStagingDir();
236
+ } else {
237
+ void downloadPromise.then(removeStagingDir, removeStagingDir);
238
+ }
144
239
  if (origDebug !== undefined) {
145
240
  process.env.DEBUG = origDebug;
146
241
  }
@@ -70,7 +70,54 @@ describe("runSpawn", () => {
70
70
 
71
71
  expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
72
72
  expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
73
+ expect(hasActiveSpawn()).toBe(true);
74
+
75
+ child.emit("close", null, "SIGKILL");
76
+ await expect(result).rejects.toBeInstanceOf(SpawnSignalError);
77
+ expect(hasActiveSpawn()).toBe(false);
78
+ expect(process.listenerCount("SIGTERM")).toBe(sigterm.count);
79
+ });
80
+
81
+ it("settles after a signaled child exits while captured pipes stay open", async () => {
82
+ const child = Object.assign(createChild(), {
83
+ stdout: new PassThrough(),
84
+ stderr: new PassThrough(),
85
+ });
86
+ mocks.spawn.mockReturnValue(child);
87
+ const sigterm = trackSignal("SIGTERM");
88
+ const result = runSpawnCapture("jscodeshift", ["--version"]);
89
+ const signalHandler = sigterm.added();
90
+
91
+ signalHandler?.("SIGTERM");
92
+ signalHandler?.("SIGTERM");
93
+ child.emit("exit", null, "SIGKILL");
94
+
73
95
  await expect(result).rejects.toBeInstanceOf(SpawnSignalError);
96
+ expect(child.stdout.destroyed).toBe(true);
97
+ expect(child.stderr.destroyed).toBe(true);
98
+ expect(hasActiveSpawn()).toBe(false);
99
+ expect(process.listenerCount("SIGTERM")).toBe(sigterm.count);
100
+ });
101
+
102
+ it("settles on a third signal when the child does not exit", async () => {
103
+ const child = createChild();
104
+ mocks.spawn.mockReturnValue(child);
105
+ const sigterm = trackSignal("SIGTERM");
106
+ const result = runSpawn("assistant-ui", ["create"]);
107
+ const signalHandler = sigterm.added();
108
+
109
+ signalHandler?.("SIGTERM");
110
+ signalHandler?.("SIGTERM");
111
+ signalHandler?.("SIGTERM");
112
+
113
+ await expect(result).rejects.toMatchObject({
114
+ signal: "SIGTERM",
115
+ forwarded: true,
116
+ });
117
+ expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
118
+ expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
119
+ expect(child.kill).toHaveBeenCalledTimes(2);
120
+ expect(hasActiveSpawn()).toBe(false);
74
121
  expect(process.listenerCount("SIGTERM")).toBe(sigterm.count);
75
122
  });
76
123
 
@@ -3,15 +3,18 @@ import { spawn } from "cross-spawn";
3
3
  export class SpawnExitError extends Error {
4
4
  code: number;
5
5
  stderr: string;
6
+ stdout: string;
6
7
 
7
- constructor(code: number, stderr = "") {
8
+ constructor(code: number, stderr = "", stdout = "") {
9
+ const output = [stdout, stderr].filter(Boolean).join("\n");
8
10
  super(
9
- stderr
10
- ? `Process exited with code ${code}\n${stderr}`
11
+ output
12
+ ? `Process exited with code ${code}\n${output}`
11
13
  : `Process exited with code ${code}`,
12
14
  );
13
15
  this.code = code;
14
16
  this.stderr = stderr;
17
+ this.stdout = stdout;
15
18
  }
16
19
  }
17
20
 
@@ -51,6 +54,7 @@ function spawnProcess(
51
54
  cwd,
52
55
  });
53
56
  let forwardedSignal: NodeJS.Signals | null = null;
57
+ let escalated = false;
54
58
  let settled = false;
55
59
  let stdout = "";
56
60
  let stderr = "";
@@ -72,16 +76,25 @@ function spawnProcess(
72
76
  process.off("SIGTERM", onSigterm);
73
77
  };
74
78
 
75
- const rejectWithSignal = (signal: NodeJS.Signals) => {
79
+ const rejectForwardedSignal = () => {
80
+ if (settled || forwardedSignal === null) return false;
76
81
  settled = true;
82
+ child.stdout?.destroy();
83
+ child.stderr?.destroy();
77
84
  cleanup();
78
- reject(new SpawnSignalError(signal, forwardedSignal !== null));
85
+ reject(new SpawnSignalError(forwardedSignal, true));
86
+ return true;
79
87
  };
80
88
 
81
89
  const forwardSignal = (signal: NodeJS.Signals) => {
82
90
  if (forwardedSignal !== null) {
83
- child.kill("SIGKILL");
84
- rejectWithSignal(forwardedSignal);
91
+ if (!escalated) {
92
+ escalated = true;
93
+ child.kill("SIGKILL");
94
+ return;
95
+ }
96
+
97
+ rejectForwardedSignal();
85
98
  return;
86
99
  }
87
100
 
@@ -97,23 +110,22 @@ function spawnProcess(
97
110
 
98
111
  child.on("error", (error) => {
99
112
  if (settled) return;
113
+ if (rejectForwardedSignal()) return;
100
114
  settled = true;
101
115
  cleanup();
102
- if (forwardedSignal !== null) {
103
- reject(new SpawnSignalError(forwardedSignal, true));
104
- } else {
105
- reject(error);
106
- }
116
+ reject(error);
117
+ });
118
+ // "close" waits for stdio pipes, which a grandchild can hold open after
119
+ // the child is reaped, so a forwarded signal settles on "exit".
120
+ child.on("exit", () => {
121
+ rejectForwardedSignal();
107
122
  });
108
123
  child.on("close", (code, signal) => {
109
124
  if (settled) return;
125
+ if (rejectForwardedSignal()) return;
110
126
  settled = true;
111
127
  cleanup();
112
- if (forwardedSignal !== null) {
113
- reject(new SpawnSignalError(forwardedSignal, true));
114
- } else {
115
- resolve({ code, signal: signal ?? null, stdout, stderr });
116
- }
128
+ resolve({ code, signal: signal ?? null, stdout, stderr });
117
129
  });
118
130
  });
119
131
  }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it, onTestFinished } from "vitest";
2
+ import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, relative } from "node:path";
5
+ import { getRelevantFiles, countFilesToProcess } from "./transform";
6
+
7
+ const fixture = () => {
8
+ const directory = mkdtempSync(join(tmpdir(), "aui-transform-files-"));
9
+ onTestFinished(() => rmSync(directory, { recursive: true, force: true }));
10
+ const write = (
11
+ name: string,
12
+ source = 'import { useAssistantApi } from "@assistant-ui/react";',
13
+ ) => {
14
+ const file = join(directory, name);
15
+ mkdirSync(dirname(file), { recursive: true });
16
+ writeFileSync(file, source);
17
+ return file;
18
+ };
19
+ return { directory, write };
20
+ };
21
+
22
+ describe("codemod file discovery", () => {
23
+ it.each(["js", "jsx", "ts", "tsx"])(
24
+ "accepts an explicit .%s target",
25
+ (extension) => {
26
+ const { write } = fixture();
27
+ const file = write(`my app.${extension}`);
28
+ expect(getRelevantFiles(file)).toEqual([file]);
29
+ expect(countFilesToProcess(file)).toBe(1);
30
+ expect(getRelevantFiles(relative(process.cwd(), file))).toEqual([file]);
31
+ },
32
+ );
33
+
34
+ it("keeps directory discovery filters", () => {
35
+ const { directory, write } = fixture();
36
+ const wanted = write("src/app.tsx");
37
+ for (const file of [
38
+ "node_modules/dependency.ts",
39
+ "dist/index.js",
40
+ "build/app.js",
41
+ "app.min.js",
42
+ "app.bundle.js",
43
+ "README.md",
44
+ ])
45
+ write(file);
46
+ write("unrelated.ts", "export const value = 1;");
47
+ expect(getRelevantFiles(directory)).toEqual([wanted]);
48
+ });
49
+
50
+ it.each([
51
+ "export const value = 1;",
52
+ 'import { useAui } from "@/lib/aui"; const aui = useAui(); aui.thread().getState();',
53
+ ])("accepts explicitly named files without package text: %s", (source) => {
54
+ const { write } = fixture();
55
+ const file = write("app.tsx", source);
56
+ expect(getRelevantFiles(file)).toEqual([file]);
57
+ expect(countFilesToProcess(file)).toBe(1);
58
+ });
59
+
60
+ it("skips unsupported explicit files", () => {
61
+ const { write } = fixture();
62
+ expect(getRelevantFiles(write("README.md"))).toEqual([]);
63
+ });
64
+
65
+ it("rejects a missing target rather than silently reporting no work", () => {
66
+ const { directory } = fixture();
67
+ expect(() => getRelevantFiles(join(directory, "missing.tsx"))).toThrow(
68
+ /ENOENT/,
69
+ );
70
+ });
71
+ });