@united-robotics/cli 0.4.1 → 0.4.3
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/index.js +137 -21
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
6
|
-
import { homedir } from "os";
|
|
7
|
-
import { join } from "path";
|
|
5
|
+
import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
6
|
+
import { homedir, tmpdir } from "os";
|
|
7
|
+
import { basename, join } from "path";
|
|
8
8
|
import { spawnSync } from "child_process";
|
|
9
9
|
var configDir = join(homedir(), ".united-robotics");
|
|
10
10
|
var configPath = join(configDir, "config.json");
|
|
@@ -40,36 +40,61 @@ program.command("team").argument("<cmd>").action(async (cmd) => {
|
|
|
40
40
|
});
|
|
41
41
|
var project = program.command("project");
|
|
42
42
|
project.command("ls").option("--team <teamId>").action(async (opts) => console.log(JSON.stringify(await api(`/api/projects${opts.team ? `?team=${encodeURIComponent(opts.team)}` : ""}`), null, 2)));
|
|
43
|
-
project.command("show").argument("<projectId>").action(async (projectId) => console.log(JSON.stringify({ projectId, repos: await
|
|
44
|
-
project.command("clone").argument("<projectId>").option("--dest <path>").action(async (projectId, opts) => {
|
|
45
|
-
const repos = await
|
|
43
|
+
project.command("show").argument("<projectId>").action(async (projectId) => console.log(JSON.stringify({ projectId, repos: await projectRepos(projectId) }, null, 2)));
|
|
44
|
+
project.command("clone").argument("<projectId>").option("--dest <path>").option("--in-place", "clone into the current directory so AGENTS.md and workspace files land at the project root").option("--no-submodules", "clone primary repo only").option("--name <name>", "Git user.name for Vercel-compatible commits", "exis[ai]").option("--email <email>", "Git user.email for Vercel-compatible commits", "gotexis@gmail.com").action(async (projectId, opts) => {
|
|
45
|
+
const repos = await projectRepos(projectId);
|
|
46
46
|
const primary = repos.find((r) => r.kind === "primary") ?? repos[0];
|
|
47
47
|
if (!primary) throw new Error("No repo found");
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
const token = await projectGithubToken(projectId);
|
|
49
|
+
const credentials = createGitAskpass(token.token);
|
|
50
|
+
try {
|
|
51
|
+
if (opts.inPlace && opts.dest) throw new Error("Use either --in-place or --dest, not both.");
|
|
52
|
+
const dest = opts.inPlace ? "." : opts.dest;
|
|
53
|
+
if (opts.inPlace) assertInPlaceCloneTarget(process.cwd());
|
|
54
|
+
const cloneArgs = ["clone", primary.url];
|
|
55
|
+
if (dest) cloneArgs.push(dest);
|
|
56
|
+
runGit(cloneArgs, { cwd: process.cwd(), env: credentials.env, authHint: true });
|
|
57
|
+
const worktree = dest ?? repoDirName(primary.url);
|
|
58
|
+
configureGitIdentity(worktree, opts.name, opts.email, credentials.env);
|
|
59
|
+
if (opts.submodules) initProjectWorktree(worktree, opts.name, opts.email, credentials.env);
|
|
60
|
+
printCloneSuccess(projectId, worktree, token);
|
|
61
|
+
} finally {
|
|
62
|
+
credentials.cleanup();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
project.command("init").argument("<projectId>").option("--cwd <path>", "workspace path", process.cwd()).option("--name <name>", "Git user.name for Vercel-compatible commits", "exis[ai]").option("--email <email>", "Git user.email for Vercel-compatible commits", "gotexis@gmail.com").action(async (projectId, opts) => {
|
|
66
|
+
const token = await projectGithubToken(projectId);
|
|
67
|
+
const credentials = createGitAskpass(token.token);
|
|
68
|
+
try {
|
|
69
|
+
initProjectWorktree(opts.cwd, opts.name, opts.email, credentials.env);
|
|
70
|
+
console.log(`Initialized ${projectId} workspace at ${opts.cwd}`);
|
|
71
|
+
printDetachedHeadNotice();
|
|
72
|
+
} finally {
|
|
73
|
+
credentials.cleanup();
|
|
74
|
+
}
|
|
52
75
|
});
|
|
53
76
|
program.command("repo").argument("<cmd>").argument("<projectId>").action(async (cmd, projectId) => {
|
|
54
77
|
if (cmd !== "ls") throw new Error("supported: ur repo ls <projectId>");
|
|
55
|
-
console.log(JSON.stringify(await
|
|
78
|
+
console.log(JSON.stringify(await projectRepos(projectId), null, 2));
|
|
56
79
|
});
|
|
57
80
|
var git = program.command("git").description("Git helpers for tenant workspaces");
|
|
58
|
-
git.command("identity").option("--name <name>", "Git user.name", "exis[ai]").option("--email <email>", "Git user.email", "gotexis@gmail.com").option("--global", "write global git config instead of current repo").action((opts) => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
81
|
+
git.command("identity").option("--name <name>", "Git user.name", "exis[ai]").option("--email <email>", "Git user.email", "gotexis@gmail.com").option("--global", "write global git config instead of current repo").option("--recursive", "also apply to initialized submodules").action((opts) => {
|
|
82
|
+
if (opts.global) {
|
|
83
|
+
runGit(["config", "--global", "user.name", opts.name], { cwd: process.cwd() });
|
|
84
|
+
runGit(["config", "--global", "user.email", opts.email], { cwd: process.cwd() });
|
|
85
|
+
} else {
|
|
86
|
+
configureGitIdentity(process.cwd(), opts.name, opts.email);
|
|
87
|
+
if (opts.recursive) configureSubmoduleIdentities(process.cwd(), opts.name, opts.email);
|
|
63
88
|
}
|
|
64
89
|
console.log(`Configured git committer identity: ${opts.name} <${opts.email}>`);
|
|
65
90
|
});
|
|
66
91
|
var github = program.command("github");
|
|
67
|
-
github.command("token").option("--project <projectId>", "project id", "skinspirit-main").option("--repo <repo>", "repo id, name, full name, or URL within the project").option("--json", "emit full JSON").action(async (opts) => {
|
|
92
|
+
github.command("token").option("--project <projectId>", "project id", "skinspirit-main").option("--repo <repo>", "repo id, name, full name, or URL within the project").option("--json", "emit full JSON").option("--redact", "redact token in JSON output").action(async (opts) => {
|
|
68
93
|
const result = await api(`/api/projects/${opts.project}/github-token`, {
|
|
69
94
|
method: "POST",
|
|
70
95
|
body: JSON.stringify(opts.repo ? { repo: opts.repo } : {})
|
|
71
96
|
});
|
|
72
|
-
if (opts.json) console.log(JSON.stringify(result, null, 2));
|
|
97
|
+
if (opts.json) console.log(JSON.stringify(opts.redact ? redactTokenResponse(result) : result, null, 2));
|
|
73
98
|
else console.log(result.token);
|
|
74
99
|
});
|
|
75
100
|
var vercel = program.command("vercel").description("Tenant-scoped Vercel CI/CD status via United Robotics broker");
|
|
@@ -86,9 +111,100 @@ program.command("cicd").description("Alias for tenant-scoped CI/CD deployment st
|
|
|
86
111
|
const params = new URLSearchParams({ limit: opts.limit });
|
|
87
112
|
console.log(JSON.stringify(await api(`/api/projects/${opts.project}/vercel/deployments?${params.toString()}`), null, 2));
|
|
88
113
|
});
|
|
89
|
-
program.command("codex").argument("<cmd>").option("--project <projectId>", "project id", "skinspirit-main").action((cmd, opts) => {
|
|
114
|
+
program.command("codex").argument("<cmd>").option("--project <projectId>", "project id", "skinspirit-main").option("--include-token", "include the currently stored PAT in the generated prompt").action((cmd, opts) => {
|
|
90
115
|
if (cmd !== "onboard") throw new Error("supported: ur codex onboard");
|
|
91
116
|
const cfg = load();
|
|
92
|
-
|
|
117
|
+
const loginToken = opts.includeToken ? cfg.token ?? "<TOKEN>" : "<TOKEN>";
|
|
118
|
+
console.log(`Establish new agent workspace in the current empty Codex project root by globally installing \`@united-robotics/cli\` module (ensuring there is a working node, nvm and git) and authenticate using token "ur login --token ${loginToken}". Then run \`ur project ls\` and \`ur project clone ${opts.project} --in-place\`; in-place clone puts AGENTS.md and workspace files at the Codex project root, while internally obtaining the short-lived project-wide GitHub token, initializing private submodules, and applying the Vercel-compatible git identity. When GitHub write access is needed manually, use \`ur github token --project ${opts.project}\` for a short-lived GitHub App token, but avoid printing that token in logs unless necessary. Use that project root as codex project base from now on, and familiarize yourself.`);
|
|
119
|
+
});
|
|
120
|
+
program.parseAsync().catch((error) => {
|
|
121
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
122
|
+
process.exit(1);
|
|
93
123
|
});
|
|
94
|
-
|
|
124
|
+
async function projectRepos(projectId) {
|
|
125
|
+
return await api(`/api/projects/${projectId}/repos`);
|
|
126
|
+
}
|
|
127
|
+
async function projectGithubToken(projectId) {
|
|
128
|
+
return await api(`/api/projects/${projectId}/github-token`, { method: "POST", body: JSON.stringify({}) });
|
|
129
|
+
}
|
|
130
|
+
function createGitAskpass(token) {
|
|
131
|
+
const dir = mkdtempSync(join(tmpdir(), "ur-git-credentials-"));
|
|
132
|
+
const askpass = join(dir, "askpass.sh");
|
|
133
|
+
writeFileSync(askpass, `#!/bin/sh
|
|
134
|
+
case "$1" in
|
|
135
|
+
*Username*) printf '%s\\n' 'x-access-token' ;;
|
|
136
|
+
*Password*) printf '%s\\n' '${shellSingleQuoteSafe(token)}' ;;
|
|
137
|
+
*) printf '\\n' ;;
|
|
138
|
+
esac
|
|
139
|
+
`, { mode: 448 });
|
|
140
|
+
chmodSync(askpass, 448);
|
|
141
|
+
return {
|
|
142
|
+
env: { ...process.env, GIT_ASKPASS: askpass, GIT_TERMINAL_PROMPT: "0" },
|
|
143
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true })
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function initProjectWorktree(worktree, name, email, env = process.env) {
|
|
147
|
+
configureGitIdentity(worktree, name, email, env);
|
|
148
|
+
runGit(["submodule", "sync", "--recursive"], { cwd: worktree, env, authHint: true, allowFailure: true });
|
|
149
|
+
runGit(["submodule", "update", "--init", "--recursive"], { cwd: worktree, env, authHint: true });
|
|
150
|
+
configureSubmoduleIdentities(worktree, name, email, env);
|
|
151
|
+
printDetachedHeadNotice();
|
|
152
|
+
}
|
|
153
|
+
function configureGitIdentity(cwd, name, email, env = process.env) {
|
|
154
|
+
runGit(["config", "--local", "user.name", name], { cwd, env });
|
|
155
|
+
runGit(["config", "--local", "user.email", email], { cwd, env });
|
|
156
|
+
}
|
|
157
|
+
function configureSubmoduleIdentities(worktree, name, email, env = process.env) {
|
|
158
|
+
const result = runGit(["submodule", "foreach", "--recursive", `git config --local user.name '${shellSingleQuoteSafe(name)}' && git config --local user.email '${shellSingleQuoteSafe(email)}'`], { cwd: worktree, env, allowFailure: true });
|
|
159
|
+
if (result.status !== 0) console.warn("Warning: could not apply git identity to all submodules. Run `ur git identity --recursive` after submodules are initialized.");
|
|
160
|
+
}
|
|
161
|
+
function runGit(args, options = { cwd: process.cwd() }) {
|
|
162
|
+
const gitArgs = options.authHint ? ["-c", "credential.helper=", "-c", "credential.useHttpPath=true", ...args] : args;
|
|
163
|
+
const result = spawnSync(gitBin(), gitArgs, { cwd: options.cwd, env: options.env ?? process.env, encoding: "utf8" });
|
|
164
|
+
if (result.stdout) process.stdout.write(redactSecrets(result.stdout));
|
|
165
|
+
if (result.stderr) process.stderr.write(redactSecrets(result.stderr));
|
|
166
|
+
const status = result.status ?? 1;
|
|
167
|
+
if (status !== 0 && !options.allowFailure) {
|
|
168
|
+
if (options.authHint && /repository not found|authentication failed|could not read username|terminal prompts disabled/i.test(`${result.stdout}
|
|
169
|
+
${result.stderr}`)) {
|
|
170
|
+
throw new Error("GitHub authentication failed while accessing a private repo. The repo may exist, but GitHub credentials were unavailable or rejected. Re-run `ur login`, then `ur project clone <projectId> --in-place` from an empty project root so the CLI can inject a short-lived GitHub App token internally.");
|
|
171
|
+
}
|
|
172
|
+
throw new Error(`git ${safeArgs(args)} failed with exit ${status}`);
|
|
173
|
+
}
|
|
174
|
+
return { status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
175
|
+
}
|
|
176
|
+
function assertInPlaceCloneTarget(cwd) {
|
|
177
|
+
const entries = readdirSync(cwd).filter((entry) => ![".DS_Store"].includes(entry));
|
|
178
|
+
if (entries.length > 0) {
|
|
179
|
+
throw new Error(`Cannot in-place clone into a non-empty directory (${cwd}). Start from an empty Codex project root, or use --dest <path> intentionally.`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function gitBin() {
|
|
183
|
+
return process.env.UR_GIT_BIN || (process.platform === "darwin" ? "/usr/bin/git" : "git");
|
|
184
|
+
}
|
|
185
|
+
function repoDirName(url) {
|
|
186
|
+
return basename(url.replace(/\.git$/, ""));
|
|
187
|
+
}
|
|
188
|
+
function printCloneSuccess(projectId, worktree, token) {
|
|
189
|
+
const repoList = token.repos?.length ? ` (${token.repos.join(", ")})` : "";
|
|
190
|
+
console.log(`Cloned ${projectId} to ${worktree} using an internal short-lived GitHub App token${repoList}.`);
|
|
191
|
+
}
|
|
192
|
+
function printDetachedHeadNotice() {
|
|
193
|
+
console.log("Note: initialized submodules may be in detached HEAD state. Before editing a submodule repo, run `git switch <branch>` inside that submodule.");
|
|
194
|
+
}
|
|
195
|
+
function redactTokenResponse(result) {
|
|
196
|
+
return { ...result, token: redactToken(result.token) };
|
|
197
|
+
}
|
|
198
|
+
function redactSecrets(text) {
|
|
199
|
+
return text.replace(/ghs_[A-Za-z0-9_]+/g, "[REDACTED_GITHUB_TOKEN]").replace(/pat_[A-Za-z0-9_-]+/g, "[REDACTED_PAT]");
|
|
200
|
+
}
|
|
201
|
+
function redactToken(token) {
|
|
202
|
+
if (!token) return "";
|
|
203
|
+
return `${token.slice(0, 8)}\u2026redacted`;
|
|
204
|
+
}
|
|
205
|
+
function safeArgs(args) {
|
|
206
|
+
return args.map((arg) => /token|password|authorization/i.test(arg) ? "***" : JSON.stringify(arg)).join(" ");
|
|
207
|
+
}
|
|
208
|
+
function shellSingleQuoteSafe(value) {
|
|
209
|
+
return String(value).replace(/'/g, `'"'"'`);
|
|
210
|
+
}
|