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.
- package/dist/codemods/utils/createTransformer.d.ts +13 -16
- package/dist/codemods/utils/createTransformer.d.ts.map +1 -1
- package/dist/codemods/utils/resolveBinding.d.ts +3 -0
- package/dist/codemods/utils/resolveBinding.d.ts.map +1 -0
- package/dist/codemods/utils/resolveBinding.js +78 -0
- package/dist/codemods/utils/resolveBinding.js.map +1 -0
- package/dist/codemods/v0-11/content-part-to-message-part.d.ts +1 -3
- package/dist/codemods/v0-11/content-part-to-message-part.d.ts.map +1 -1
- package/dist/codemods/v0-12/assistant-api-to-aui.d.ts +1 -3
- package/dist/codemods/v0-12/assistant-api-to-aui.d.ts.map +1 -1
- package/dist/codemods/v0-12/assistant-api-to-aui.js +51 -99
- package/dist/codemods/v0-12/assistant-api-to-aui.js.map +1 -1
- package/dist/codemods/v0-12/event-names-to-camelcase.d.ts +1 -3
- package/dist/codemods/v0-12/event-names-to-camelcase.d.ts.map +1 -1
- package/dist/codemods/v0-12/primitive-if-to-aui-if.d.ts +1 -3
- package/dist/codemods/v0-12/primitive-if-to-aui-if.d.ts.map +1 -1
- package/dist/codemods/v0-15/aui-accessor-calls-to-properties.d.ts +1 -3
- package/dist/codemods/v0-15/aui-accessor-calls-to-properties.d.ts.map +1 -1
- package/dist/codemods/v0-15/aui-accessor-calls-to-properties.js +33 -5
- package/dist/codemods/v0-15/aui-accessor-calls-to-properties.js.map +1 -1
- package/dist/codemods/v0-8/ui-package-split.d.ts +1 -3
- package/dist/codemods/v0-8/ui-package-split.d.ts.map +1 -1
- package/dist/codemods/v0-9/edge-package-split.d.ts +1 -3
- package/dist/codemods/v0-9/edge-package-split.d.ts.map +1 -1
- package/dist/commands/add.d.ts +13 -16
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +3 -3
- package/dist/commands/agent.d.ts +1 -4
- package/dist/commands/agent.d.ts.map +1 -1
- package/dist/commands/agent.js +16 -13
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/create.d.ts +43 -39
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +40 -26
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.d.ts +17 -20
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/info.d.ts +4 -6
- package/dist/commands/info.d.ts.map +1 -1
- package/dist/commands/info.js +3 -3
- package/dist/commands/init.d.ts +8 -10
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +3 -4
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/mcp.d.ts +1 -4
- package/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +2 -2
- package/dist/commands/update.d.ts +1 -4
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +1 -1
- package/dist/commands/upgrade.d.ts +7 -10
- package/dist/commands/upgrade.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -0
- package/dist/lib/agent-skill.d.ts +11 -10
- package/dist/lib/agent-skill.d.ts.map +1 -1
- package/dist/lib/agent-skill.js +78 -2
- package/dist/lib/agent-skill.js.map +1 -1
- package/dist/lib/create-project.d.ts +33 -32
- package/dist/lib/create-project.d.ts.map +1 -1
- package/dist/lib/create-project.js +91 -5
- package/dist/lib/create-project.js.map +1 -1
- package/dist/lib/handle-cli-error.d.ts +1 -4
- package/dist/lib/handle-cli-error.d.ts.map +1 -1
- package/dist/lib/install-ai-sdk-lib.d.ts +1 -4
- package/dist/lib/install-ai-sdk-lib.d.ts.map +1 -1
- package/dist/lib/install-edge-lib.d.ts +1 -4
- package/dist/lib/install-edge-lib.d.ts.map +1 -1
- package/dist/lib/run-spawn.d.ts +17 -19
- package/dist/lib/run-spawn.d.ts.map +1 -1
- package/dist/lib/run-spawn.js +25 -10
- package/dist/lib/run-spawn.js.map +1 -1
- package/dist/lib/transform-options.d.ts +5 -8
- package/dist/lib/transform-options.d.ts.map +1 -1
- package/dist/lib/transform.d.ts +13 -16
- package/dist/lib/transform.d.ts.map +1 -1
- package/dist/lib/transform.js +19 -35
- package/dist/lib/transform.js.map +1 -1
- package/dist/lib/upgrade.d.ts +3 -6
- package/dist/lib/upgrade.d.ts.map +1 -1
- package/dist/lib/upgrade.js +6 -11
- package/dist/lib/upgrade.js.map +1 -1
- package/dist/lib/utils/config.d.ts +18 -21
- package/dist/lib/utils/config.d.ts.map +1 -1
- package/dist/lib/utils/file-scanner.d.ts +12 -15
- package/dist/lib/utils/file-scanner.d.ts.map +1 -1
- package/dist/lib/utils/logger.d.ts +7 -10
- package/dist/lib/utils/logger.d.ts.map +1 -1
- package/dist/lib/utils/package-installer.d.ts +7 -10
- package/dist/lib/utils/package-installer.d.ts.map +1 -1
- package/dist/lib/utils/package-json.d.ts +1 -4
- package/dist/lib/utils/package-json.d.ts.map +1 -1
- package/dist/lib/utils/package-manager.d.ts +9 -12
- package/dist/lib/utils/package-manager.d.ts.map +1 -1
- package/dist/lib/utils/package-manager.js +13 -2
- package/dist/lib/utils/package-manager.js.map +1 -1
- package/dist/lib/utils/registry.d.ts +5 -8
- package/dist/lib/utils/registry.d.ts.map +1 -1
- package/dist/lib/utils/workspace.d.ts +2 -5
- package/dist/lib/utils/workspace.d.ts.map +1 -1
- package/dist/program.d.ts +1 -4
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +6 -6
- package/dist/run.d.ts +1 -4
- package/dist/run.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/codemods/utils/resolveBinding.test.ts +107 -0
- package/src/codemods/utils/resolveBinding.ts +130 -0
- package/src/codemods/v0-12/__tests__/assistant-api-to-aui.test.ts +256 -0
- package/src/codemods/v0-12/assistant-api-to-aui.ts +125 -225
- package/src/codemods/v0-15/__tests__/aui-accessor-calls-to-properties.test.ts +101 -0
- package/src/codemods/v0-15/aui-accessor-calls-to-properties.ts +60 -5
- package/src/commands/agent.ts +15 -22
- package/src/commands/create.ts +53 -20
- package/src/commands/init.ts +1 -2
- package/src/lib/agent-skill.ts +103 -1
- package/src/lib/create-project.ts +99 -4
- package/src/lib/run-spawn.test.ts +47 -0
- package/src/lib/run-spawn.ts +29 -17
- package/src/lib/transform-files.test.ts +71 -0
- package/src/lib/transform.test.ts +69 -1
- package/src/lib/transform.ts +23 -38
- package/src/lib/upgrade.ts +9 -13
- package/src/lib/utils/package-manager.ts +24 -1
- package/plugin/.claude-plugin/plugin.json +0 -8
- package/plugin/skills/assistant-ui/SKILL.md +0 -199
- package/plugin/skills/assistant-ui/shipables.json +0 -22
package/src/commands/create.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
655
|
-
//
|
|
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
|
-
|
|
732
|
+
resetProjectDir();
|
|
700
733
|
logger.warn(
|
|
701
734
|
"Template not found at release tag, downloading from HEAD",
|
|
702
735
|
);
|
package/src/commands/init.ts
CHANGED
|
@@ -83,8 +83,7 @@ export const init = new Command()
|
|
|
83
83
|
logger.break();
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
const createArgs
|
|
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");
|
package/src/lib/agent-skill.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
119
|
-
dir:
|
|
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
|
|
package/src/lib/run-spawn.ts
CHANGED
|
@@ -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
|
-
|
|
10
|
-
? `Process exited with code ${code}\n${
|
|
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
|
|
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(
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
+
});
|