@rigkit/provider-gcloud-cli 0.0.0-canary-20260518T014918-c5bc0c2

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/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @rigkit/provider-gcloud-cli
2
+
3
+ Copy local `gcloud` config/auth files into rigkit workspaces.
4
+
5
+ This provider does not own Google OAuth. It requires the developer's local machine to have `gcloud` installed and authenticated, then copies selected files from the local gcloud config directory into a workspace. This makes normal VM-side commands such as `gcloud auth list` behave like they do locally.
6
+
7
+ ```ts
8
+ import { workflow } from "@rigkit/sdk";
9
+ import {
10
+ copyGcloudConfig,
11
+ gcloudConfigCopyInjectionSteps,
12
+ gcloudCopiedConfigReadyCommand,
13
+ } from "@rigkit/provider-gcloud-cli";
14
+
15
+ const app = workflow("example", {
16
+ providers: {
17
+ gcloudConfig: copyGcloudConfig.provider({
18
+ requireAuth: true,
19
+ }),
20
+ },
21
+ });
22
+
23
+ // Inside a workspace operation:
24
+ const gcloudConfigFiles = await providers.gcloudConfig.configFiles();
25
+ for (const step of gcloudConfigCopyInjectionSteps(gcloudConfigFiles)) {
26
+ await vm.exec(step.command, { name: step.name, env: step.env });
27
+ }
28
+
29
+ const verified = await vm.probe(gcloudCopiedConfigReadyCommand());
30
+ if (!verified.ok) throw new Error("gcloud did not accept the copied config files");
31
+ ```
32
+
33
+ By default, provider startup requires local `gcloud` to be installed and authenticated before Rigkit runs project commands. If local `gcloud` is missing, startup fails with the Google Cloud SDK install URL. If it is not authenticated, startup asks the user to run `gcloud auth login`.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@rigkit/provider-gcloud-cli",
3
+ "version": "0.0.0-canary-20260518T014918-c5bc0c2",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/freestyle-sh/rigkit.git",
8
+ "directory": "packages/provider-gcloud-cli"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "dependencies": {
19
+ "zod": "^4",
20
+ "@rigkit/sdk": "0.0.0-canary-20260518T014918-c5bc0c2",
21
+ "@rigkit/engine": "0.0.0-canary-20260518T014918-c5bc0c2"
22
+ },
23
+ "devDependencies": {
24
+ "@types/bun": "latest",
25
+ "typescript": "latest"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc --noEmit",
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "bun test"
34
+ }
35
+ }
@@ -0,0 +1,46 @@
1
+ export type DurationInput = number | string;
2
+
3
+ const units: Record<string, number> = {
4
+ ms: 1,
5
+ millisecond: 1,
6
+ milliseconds: 1,
7
+ s: 1000,
8
+ sec: 1000,
9
+ secs: 1000,
10
+ second: 1000,
11
+ seconds: 1000,
12
+ m: 60 * 1000,
13
+ min: 60 * 1000,
14
+ mins: 60 * 1000,
15
+ minute: 60 * 1000,
16
+ minutes: 60 * 1000,
17
+ h: 60 * 60 * 1000,
18
+ hr: 60 * 60 * 1000,
19
+ hrs: 60 * 60 * 1000,
20
+ hour: 60 * 60 * 1000,
21
+ hours: 60 * 60 * 1000,
22
+ d: 24 * 60 * 60 * 1000,
23
+ day: 24 * 60 * 60 * 1000,
24
+ days: 24 * 60 * 60 * 1000,
25
+ };
26
+
27
+ export function parseDurationMs(value: DurationInput): number {
28
+ if (typeof value === "number") {
29
+ if (!Number.isFinite(value) || value < 0) throw new Error(`Duration must be a non-negative number`);
30
+ return Math.floor(value);
31
+ }
32
+
33
+ const trimmed = value.trim().toLowerCase();
34
+ const match = /^(\d+(?:\.\d+)?)\s*([a-z]+)$/.exec(trimmed);
35
+ if (!match) {
36
+ throw new Error(`Invalid duration ${JSON.stringify(value)}. Use values like "30m", "6h", or "1day".`);
37
+ }
38
+
39
+ const amount = Number(match[1]);
40
+ const unit = units[match[2]!];
41
+ if (!unit) {
42
+ throw new Error(`Invalid duration unit ${JSON.stringify(match[2])}`);
43
+ }
44
+
45
+ return Math.floor(amount * unit);
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { defineProvider, type WorkflowProviderDefinition } from "@rigkit/sdk";
2
+ import type { BaseProviderPlugin } from "@rigkit/engine";
3
+ import * as z from "zod/v4-mini";
4
+ import {
5
+ assertLocalGcloudReady,
6
+ createGcloudConfigCopyController,
7
+ GCLOUD_CONFIG_COPY_PROVIDER_ID,
8
+ requiresLocalGcloudAuth,
9
+ type GcloudConfigCopyConfig,
10
+ type GcloudConfigCopyRuntime,
11
+ } from "./provider.ts";
12
+ import { createGcloudAuthStore } from "./store.ts";
13
+
14
+ const gcloudConfigCopyProviderConfigSchema = z.object({
15
+ command: z.optional(z.string()),
16
+ requireAuth: z.optional(z.boolean()),
17
+ key: z.optional(z.string()),
18
+ account: z.optional(z.string()),
19
+ scopes: z.optional(z.array(z.string())),
20
+ installUrl: z.optional(z.string()),
21
+ accessTokenLifetimeSeconds: z.optional(z.number()),
22
+ configDir: z.optional(z.string()),
23
+ });
24
+
25
+ export type GcloudConfigCopyProviderConfig = z.output<typeof gcloudConfigCopyProviderConfigSchema>;
26
+
27
+ export type GcloudConfigCopyProviderDefinition = WorkflowProviderDefinition<
28
+ typeof GCLOUD_CONFIG_COPY_PROVIDER_ID,
29
+ GcloudConfigCopyProviderConfig,
30
+ GcloudConfigCopyRuntime
31
+ >;
32
+
33
+ export function provider(config: GcloudConfigCopyProviderDefinition["config"] = {}): GcloudConfigCopyProviderDefinition {
34
+ return defineProvider(GCLOUD_CONFIG_COPY_PROVIDER_ID, config, gcloudConfigCopyProviderPlugin);
35
+ }
36
+
37
+ export const copyGcloudConfig = {
38
+ provider,
39
+ };
40
+
41
+ export const defineGcloudConfigCopyProvider = provider;
42
+
43
+ export const gcloudConfigCopyProviderPlugin: BaseProviderPlugin = {
44
+ providerId: GCLOUD_CONFIG_COPY_PROVIDER_ID,
45
+ async createProvider({ provider, storage }) {
46
+ const config = parseGcloudConfigCopyProviderConfig(provider.config);
47
+ if (requiresLocalGcloudAuth(config)) {
48
+ await assertLocalGcloudReady(config);
49
+ }
50
+ return createGcloudConfigCopyController(config, createGcloudAuthStore(storage));
51
+ },
52
+ };
53
+
54
+ export {
55
+ assertLocalGcloudReady,
56
+ DEFAULT_GCLOUD_AUTH_SCOPES,
57
+ DEFAULT_GCLOUD_INSTALL_URL,
58
+ GCLOUD_CONFIG_COPY_PROVIDER_ID,
59
+ createGcloudConfigCopyController,
60
+ requiresLocalGcloudAuth,
61
+ } from "./provider.ts";
62
+ export {
63
+ DEFAULT_GCLOUD_ACCESS_TOKEN_EXPIRES_AT_PATH,
64
+ DEFAULT_GCLOUD_ACCESS_TOKEN_PATH,
65
+ DEFAULT_GCLOUD_CONFIG_DIR,
66
+ gcloudAccessTokenFreshCommand,
67
+ gcloudAccessTokenInjection,
68
+ gcloudConfigCopyInjection,
69
+ gcloudConfigCopyInjectionSteps,
70
+ gcloudCopiedConfigReadyCommand,
71
+ } from "./inject.ts";
72
+ export { createGcloudAuthStore, normalizeScopes } from "./store.ts";
73
+ export { RIGKIT_PROVIDER_GCLOUD_CLI_VERSION } from "./version.ts";
74
+ export type {
75
+ GcloudConfigCopyConfig,
76
+ GcloudConfigCopyRuntime,
77
+ GcloudConfigFilesOptions,
78
+ GcloudCommandResult,
79
+ GcloudCommandRunner,
80
+ GcloudFreshAccessTokenOptions,
81
+ } from "./provider.ts";
82
+ export type {
83
+ GcloudAccessCredentials,
84
+ GcloudAccessTokenInjection,
85
+ GcloudAccessTokenInjectionOptions,
86
+ GcloudConfigCopy,
87
+ GcloudConfigFile,
88
+ GcloudConfigCopyInjection,
89
+ GcloudConfigCopyInjectionOptions,
90
+ GcloudConfigCopyInjectionStep,
91
+ } from "./inject.ts";
92
+ export type { GcloudCredentialsInput, GcloudStoredCredentials } from "./store.ts";
93
+
94
+ function parseGcloudConfigCopyProviderConfig(value: unknown): GcloudConfigCopyConfig {
95
+ const result = z.safeParse(gcloudConfigCopyProviderConfigSchema, value);
96
+ if (!result.success) {
97
+ throw new Error(`Invalid gcloud config copy provider config:\n${z.prettifyError(result.error)}`);
98
+ }
99
+ return result.data;
100
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ gcloudAccessTokenFreshCommand,
4
+ gcloudAccessTokenInjection,
5
+ gcloudConfigCopyInjection,
6
+ gcloudConfigCopyInjectionSteps,
7
+ gcloudCopiedConfigReadyCommand,
8
+ } from "./inject.ts";
9
+
10
+ describe("gcloud config copy helpers", () => {
11
+ test("keeps token material in env instead of the emitted command", () => {
12
+ const injection = gcloudAccessTokenInjection({
13
+ accessToken: "secret-token",
14
+ tokenType: "Bearer",
15
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
16
+ account: "dev@example.com",
17
+ scopes: ["openid"],
18
+ });
19
+
20
+ expect(injection.command).not.toContain("secret-token");
21
+ expect(injection.env.RIGKIT_GCLOUD_CLI_ACCESS_TOKEN).toBe("secret-token");
22
+ expect(injection.env.RIGKIT_GCLOUD_CLI_ACCOUNT).toBe("dev@example.com");
23
+ expect(injection.command).toContain("gcloud config set auth/access_token_file");
24
+ expect(injection.command).toContain("gcloud config set account");
25
+ });
26
+
27
+ test("supports hour-style freshness checks", () => {
28
+ const command = gcloudAccessTokenFreshCommand({ minExpiration: "6hrs" });
29
+ expect(command).toContain("21600000");
30
+ });
31
+
32
+ test("copies gcloud config files without embedding file contents in the command", () => {
33
+ const injection = gcloudConfigCopyInjection({
34
+ sourceConfigDir: "/Users/dev/.config/gcloud",
35
+ account: "dev@example.com",
36
+ files: [
37
+ { path: "active_config", contentsBase64: Buffer.from("default").toString("base64") },
38
+ { path: "credentials.db", contentsBase64: "secret-db-base64" },
39
+ ],
40
+ });
41
+
42
+ expect(injection.command).toContain("rm -rf \"$config_dir\"");
43
+ expect(injection.command).toContain("gcloud CLI is not installed");
44
+ expect(injection.command).toContain("credentials.db");
45
+ expect(injection.command).toContain("gcloud config set account");
46
+ expect(injection.command).not.toContain("secret-db-base64");
47
+ expect(injection.env.RIGKIT_GCLOUD_CLI_CONFIG_FILE_1).toBe("secret-db-base64");
48
+ expect(injection.env.RIGKIT_GCLOUD_CLI_ACCOUNT).toBe("dev@example.com");
49
+ expect(gcloudCopiedConfigReadyCommand()).toContain("gcloud auth list");
50
+ });
51
+
52
+ test("splits copied gcloud config files into small upload steps", () => {
53
+ const steps = gcloudConfigCopyInjectionSteps(
54
+ {
55
+ sourceConfigDir: "/Users/dev/.config/gcloud",
56
+ files: [
57
+ { path: "credentials.db", contentsBase64: "1234567890abcdef" },
58
+ ],
59
+ },
60
+ { chunkSize: 8 },
61
+ );
62
+
63
+ expect(steps).toHaveLength(3);
64
+ expect(steps[0]!.name).toBe("prepare gcloud config copy");
65
+ expect(steps[1]!.env.RIGKIT_GCLOUD_CLI_CONFIG_FILE_CHUNK).toBe("12345678");
66
+ expect(steps[2]!.env.RIGKIT_GCLOUD_CLI_CONFIG_FILE_CHUNK).toBe("90abcdef");
67
+ expect(steps[1]!.command).toContain(": > \"$config_dir/credentials.db\"");
68
+ expect(steps[2]!.command).not.toContain(": > \"$config_dir/credentials.db\"");
69
+ });
70
+ });
package/src/inject.ts ADDED
@@ -0,0 +1,259 @@
1
+ import type { ExecOptions } from "@rigkit/sdk";
2
+ import { parseDurationMs, type DurationInput } from "./duration.ts";
3
+
4
+ export const DEFAULT_GCLOUD_ACCESS_TOKEN_PATH = "${HOME:-/root}/.config/rigkit/gcloud/access-token";
5
+ export const DEFAULT_GCLOUD_ACCESS_TOKEN_EXPIRES_AT_PATH = "${HOME:-/root}/.config/rigkit/gcloud/expires-at-ms";
6
+ export const DEFAULT_GCLOUD_CONFIG_DIR = "${HOME:-/root}/.config/gcloud";
7
+
8
+ export type GcloudAccessCredentials = {
9
+ accessToken: string;
10
+ tokenType: string;
11
+ expiresAt: string;
12
+ account?: string | null;
13
+ scopes: string[];
14
+ };
15
+
16
+ export type GcloudAccessTokenInjectionOptions = {
17
+ tokenPath?: string;
18
+ expiresAtPath?: string;
19
+ };
20
+
21
+ export type GcloudAccessTokenInjection = {
22
+ command: string;
23
+ env: NonNullable<ExecOptions["env"]>;
24
+ };
25
+
26
+ export type GcloudConfigFile = {
27
+ path: string;
28
+ contentsBase64: string;
29
+ };
30
+
31
+ export type GcloudConfigCopy = {
32
+ sourceConfigDir: string;
33
+ account?: string | null;
34
+ files: GcloudConfigFile[];
35
+ };
36
+
37
+ export type GcloudConfigCopyInjectionOptions = {
38
+ configDir?: string;
39
+ chunkSize?: number;
40
+ };
41
+
42
+ export type GcloudConfigCopyInjection = {
43
+ command: string;
44
+ env: NonNullable<ExecOptions["env"]>;
45
+ };
46
+
47
+ export type GcloudConfigCopyInjectionStep = GcloudConfigCopyInjection & {
48
+ name: string;
49
+ };
50
+
51
+ export function gcloudAccessTokenInjection(
52
+ credentials: GcloudAccessCredentials,
53
+ options: GcloudAccessTokenInjectionOptions = {},
54
+ ): GcloudAccessTokenInjection {
55
+ const expiresAtMs = Date.parse(credentials.expiresAt);
56
+ if (!Number.isFinite(expiresAtMs)) {
57
+ throw new Error(`Invalid gcloud credential expiration ${JSON.stringify(credentials.expiresAt)}`);
58
+ }
59
+
60
+ return {
61
+ command: [
62
+ "set -e",
63
+ 'export HOME="${HOME:-/root}"',
64
+ `token_path=${shellPathExpression(options.tokenPath, DEFAULT_GCLOUD_ACCESS_TOKEN_PATH)}`,
65
+ `expires_at_path=${shellPathExpression(options.expiresAtPath, DEFAULT_GCLOUD_ACCESS_TOKEN_EXPIRES_AT_PATH)}`,
66
+ 'command -v gcloud >/dev/null 2>&1 || { echo "gcloud CLI is not installed" >&2; exit 1; }',
67
+ 'mkdir -p "$(dirname "$token_path")" "$(dirname "$expires_at_path")"',
68
+ "umask 077",
69
+ 'printf "%s\\n" "$RIGKIT_GCLOUD_CLI_ACCESS_TOKEN" > "$token_path"',
70
+ 'printf "%s\\n" "$RIGKIT_GCLOUD_CLI_EXPIRES_AT_MS" > "$expires_at_path"',
71
+ 'gcloud config set auth/access_token_file "$token_path" >/dev/null',
72
+ 'if [ -n "${RIGKIT_GCLOUD_CLI_ACCOUNT:-}" ]; then',
73
+ ' gcloud config set account "$RIGKIT_GCLOUD_CLI_ACCOUNT" >/dev/null',
74
+ "fi",
75
+ ].join("\n"),
76
+ env: {
77
+ RIGKIT_GCLOUD_CLI_ACCESS_TOKEN: credentials.accessToken,
78
+ RIGKIT_GCLOUD_CLI_EXPIRES_AT: credentials.expiresAt,
79
+ RIGKIT_GCLOUD_CLI_EXPIRES_AT_MS: String(expiresAtMs),
80
+ RIGKIT_GCLOUD_CLI_ACCOUNT: credentials.account ?? undefined,
81
+ },
82
+ };
83
+ }
84
+
85
+ export function gcloudConfigCopyInjection(
86
+ configCopy: GcloudConfigCopy,
87
+ options: GcloudConfigCopyInjectionOptions = {},
88
+ ): GcloudConfigCopyInjection {
89
+ if (configCopy.files.length === 0) {
90
+ throw new Error("Cannot copy empty gcloud config file set");
91
+ }
92
+
93
+ const env: NonNullable<ExecOptions["env"]> = {};
94
+ const command = [
95
+ "set -e",
96
+ 'export HOME="${HOME:-/root}"',
97
+ `config_dir=${shellPathExpression(options.configDir, DEFAULT_GCLOUD_CONFIG_DIR)}`,
98
+ 'command -v gcloud >/dev/null 2>&1 || { echo "gcloud CLI is not installed" >&2; exit 1; }',
99
+ 'rm -rf "$config_dir"',
100
+ 'mkdir -p "$config_dir"',
101
+ "umask 077",
102
+ ];
103
+
104
+ configCopy.files.forEach((file, index) => {
105
+ assertSafeRelativePath(file.path);
106
+ const variable = `RIGKIT_GCLOUD_CLI_CONFIG_FILE_${index}`;
107
+ env[variable] = file.contentsBase64;
108
+ command.push(
109
+ `mkdir -p "$(dirname "$config_dir/${shellDoubleQuote(file.path)}")"`,
110
+ `printf '%s' "$${variable}" | base64 -d > "$config_dir/${shellDoubleQuote(file.path)}"`,
111
+ `chmod 600 "$config_dir/${shellDoubleQuote(file.path)}"`,
112
+ );
113
+ });
114
+ if (configCopy.account) {
115
+ env.RIGKIT_GCLOUD_CLI_ACCOUNT = configCopy.account;
116
+ command.push(
117
+ 'export CLOUDSDK_CONFIG="$config_dir"',
118
+ 'gcloud config set account "$RIGKIT_GCLOUD_CLI_ACCOUNT" >/dev/null',
119
+ );
120
+ }
121
+
122
+ return {
123
+ command: command.join("\n"),
124
+ env,
125
+ };
126
+ }
127
+
128
+ export function gcloudConfigCopyInjectionSteps(
129
+ configCopy: GcloudConfigCopy,
130
+ options: GcloudConfigCopyInjectionOptions = {},
131
+ ): GcloudConfigCopyInjectionStep[] {
132
+ if (configCopy.files.length === 0) {
133
+ throw new Error("Cannot copy empty gcloud config file set");
134
+ }
135
+
136
+ const chunkSize = normalizeChunkSize(options.chunkSize);
137
+ const configDir = shellPathExpression(options.configDir, DEFAULT_GCLOUD_CONFIG_DIR);
138
+ const steps: GcloudConfigCopyInjectionStep[] = [
139
+ {
140
+ name: "prepare gcloud config copy",
141
+ command: [
142
+ "set -e",
143
+ 'export HOME="${HOME:-/root}"',
144
+ `config_dir=${configDir}`,
145
+ 'command -v gcloud >/dev/null 2>&1 || { echo "gcloud CLI is not installed" >&2; exit 1; }',
146
+ 'rm -rf "$config_dir"',
147
+ 'mkdir -p "$config_dir"',
148
+ ].join("\n"),
149
+ env: {},
150
+ },
151
+ ];
152
+
153
+ configCopy.files.forEach((file, index) => {
154
+ assertSafeRelativePath(file.path);
155
+ const chunks = splitBase64(file.contentsBase64, chunkSize);
156
+ chunks.forEach((chunk, chunkIndex) => {
157
+ steps.push({
158
+ name: `copy gcloud config file ${file.path} ${chunkIndex + 1}/${chunks.length}`,
159
+ command: [
160
+ "set -e",
161
+ 'export HOME="${HOME:-/root}"',
162
+ `config_dir=${configDir}`,
163
+ "umask 077",
164
+ `mkdir -p "$(dirname "$config_dir/${shellDoubleQuote(file.path)}")"`,
165
+ chunkIndex === 0 ? `: > "$config_dir/${shellDoubleQuote(file.path)}"` : "",
166
+ `printf '%s' "$RIGKIT_GCLOUD_CLI_CONFIG_FILE_CHUNK" | base64 -d >> "$config_dir/${shellDoubleQuote(file.path)}"`,
167
+ `chmod 600 "$config_dir/${shellDoubleQuote(file.path)}"`,
168
+ ].filter(Boolean).join("\n"),
169
+ env: {
170
+ RIGKIT_GCLOUD_CLI_CONFIG_FILE_CHUNK: chunk,
171
+ },
172
+ });
173
+ });
174
+ });
175
+ if (configCopy.account) {
176
+ steps.push({
177
+ name: "set copied gcloud account",
178
+ command: [
179
+ "set -e",
180
+ 'export HOME="${HOME:-/root}"',
181
+ `config_dir=${configDir}`,
182
+ 'export CLOUDSDK_CONFIG="$config_dir"',
183
+ 'gcloud config set account "$RIGKIT_GCLOUD_CLI_ACCOUNT" >/dev/null',
184
+ ].join("\n"),
185
+ env: {
186
+ RIGKIT_GCLOUD_CLI_ACCOUNT: configCopy.account,
187
+ },
188
+ });
189
+ }
190
+
191
+ return steps;
192
+ }
193
+
194
+ export function gcloudAccessTokenFreshCommand(
195
+ options: GcloudAccessTokenInjectionOptions & { minExpiration?: DurationInput } = {},
196
+ ): string {
197
+ const minExpirationMs = parseDurationMs(options.minExpiration ?? 0);
198
+ return [
199
+ "set -e",
200
+ 'export HOME="${HOME:-/root}"',
201
+ `expires_at_path=${shellPathExpression(options.expiresAtPath, DEFAULT_GCLOUD_ACCESS_TOKEN_EXPIRES_AT_PATH)}`,
202
+ 'test -s "$expires_at_path"',
203
+ 'expires_at_ms="$(cat "$expires_at_path")"',
204
+ 'case "$expires_at_ms" in ""|*[!0-9]*) exit 1;; esac',
205
+ 'now_ms="$(($(date +%s) * 1000))"',
206
+ `test "$expires_at_ms" -gt "$((now_ms + ${minExpirationMs}))"`,
207
+ "gcloud auth print-access-token >/dev/null 2>&1",
208
+ ].join("\n");
209
+ }
210
+
211
+ export function gcloudCopiedConfigReadyCommand(): string {
212
+ return [
213
+ "set -e",
214
+ 'export HOME="${HOME:-/root}"',
215
+ 'gcloud auth list --filter=status:ACTIVE --format="value(account)" | grep -q .',
216
+ "gcloud auth print-access-token >/dev/null 2>&1",
217
+ ].join("\n");
218
+ }
219
+
220
+ function shellPathExpression(path: string | undefined, defaultExpression: string): string {
221
+ return path ? shellQuote(path) : `"${defaultExpression}"`;
222
+ }
223
+
224
+ function shellQuote(value: string): string {
225
+ return `'${value.replaceAll("'", "'\\''")}'`;
226
+ }
227
+
228
+ function shellDoubleQuote(value: string): string {
229
+ return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("$", "\\$").replaceAll("`", "\\`");
230
+ }
231
+
232
+ function normalizeChunkSize(value: number | undefined): number {
233
+ const chunkSize = Math.floor(value ?? 16 * 1024);
234
+ if (!Number.isFinite(chunkSize) || chunkSize < 4) {
235
+ throw new Error(`Invalid gcloud config file chunk size ${JSON.stringify(value)}`);
236
+ }
237
+ return chunkSize - (chunkSize % 4);
238
+ }
239
+
240
+ function splitBase64(value: string, chunkSize: number): string[] {
241
+ const chunks: string[] = [];
242
+ for (let index = 0; index < value.length; index += chunkSize) {
243
+ chunks.push(value.slice(index, index + chunkSize));
244
+ }
245
+ return chunks.length > 0 ? chunks : [""];
246
+ }
247
+
248
+ function assertSafeRelativePath(path: string): void {
249
+ if (
250
+ !path ||
251
+ path.startsWith("/") ||
252
+ path.includes("\0") ||
253
+ path.includes("\n") ||
254
+ path.includes("\r") ||
255
+ path.split("/").some((part) => part === "" || part === "." || part === "..")
256
+ ) {
257
+ throw new Error(`Unsafe gcloud config file path ${JSON.stringify(path)}`);
258
+ }
259
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createStateStore } from "@rigkit/engine";
6
+ import { gcloudConfigCopyProviderPlugin } from "./index.ts";
7
+ import {
8
+ GCLOUD_CONFIG_COPY_PROVIDER_ID,
9
+ assertLocalGcloudReady,
10
+ createGcloudConfigCopyController,
11
+ requiresLocalGcloudAuth,
12
+ type GcloudCommandRunner,
13
+ } from "./provider.ts";
14
+ import { createGcloudAuthStore } from "./store.ts";
15
+
16
+ describe("local gcloud config copy provider", () => {
17
+ test("requires startup auth by default", async () => {
18
+ expect(requiresLocalGcloudAuth({})).toBe(true);
19
+ expect(requiresLocalGcloudAuth({ requireAuth: true })).toBe(true);
20
+
21
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-gcloud-"));
22
+ const state = createStateStore({ projectDir });
23
+ await state.syncSchema();
24
+
25
+ await expect(
26
+ gcloudConfigCopyProviderPlugin.createProvider({
27
+ provider: {
28
+ providerId: GCLOUD_CONFIG_COPY_PROVIDER_ID,
29
+ config: { command: join(projectDir, "missing-gcloud") },
30
+ },
31
+ storage: state.providerStorage("gcloud.config.copy"),
32
+ hostStorage: state.providerStorage("gcloud.config.copy.host"),
33
+ local: { open: async () => {} },
34
+ }),
35
+ ).rejects.toThrow("Local gcloud CLI is required");
36
+ });
37
+
38
+ test("can skip startup auth explicitly", async () => {
39
+ expect(requiresLocalGcloudAuth({ requireAuth: false })).toBe(false);
40
+
41
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-gcloud-"));
42
+ const state = createStateStore({ projectDir });
43
+ await state.syncSchema();
44
+
45
+ const controller = await gcloudConfigCopyProviderPlugin.createProvider({
46
+ provider: {
47
+ providerId: GCLOUD_CONFIG_COPY_PROVIDER_ID,
48
+ config: {
49
+ command: join(projectDir, "missing-gcloud"),
50
+ requireAuth: false,
51
+ },
52
+ },
53
+ storage: state.providerStorage("gcloud.config.copy"),
54
+ hostStorage: state.providerStorage("gcloud.config.copy.host"),
55
+ local: { open: async () => {} },
56
+ });
57
+
58
+ expect(controller.providerId).toBe(GCLOUD_CONFIG_COPY_PROVIDER_ID);
59
+ });
60
+
61
+ test("fails startup when local gcloud is missing", async () => {
62
+ const runner: GcloudCommandRunner = async () => ({
63
+ stdout: "",
64
+ stderr: "command not found: gcloud",
65
+ exitCode: 127,
66
+ });
67
+
68
+ await expect(assertLocalGcloudReady({}, runner)).rejects.toThrow("Local gcloud CLI is required");
69
+ });
70
+
71
+ test("fails startup when local gcloud is not authenticated", async () => {
72
+ const runner: GcloudCommandRunner = async (_command, args) => {
73
+ if (args[0] === "--version") return { stdout: "Google Cloud SDK", stderr: "", exitCode: 0 };
74
+ return { stdout: "", stderr: "You do not currently have an active account selected.", exitCode: 1 };
75
+ };
76
+
77
+ await expect(assertLocalGcloudReady({}, runner)).rejects.toThrow("not authenticated");
78
+ });
79
+
80
+ test("mints and stores a fresh local gcloud access token", async () => {
81
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-gcloud-"));
82
+ const state = createStateStore({ projectDir });
83
+ await state.syncSchema();
84
+ const store = createGcloudAuthStore(state.providerStorage("gcloud.config.copy"));
85
+ const calls: string[][] = [];
86
+ const runner: GcloudCommandRunner = async (_command, args) => {
87
+ calls.push([...args]);
88
+ if (args[0] === "auth") return { stdout: "access-token\n", stderr: "", exitCode: 0 };
89
+ if (args[0] === "config") return { stdout: "dev@example.com\n", stderr: "", exitCode: 0 };
90
+ throw new Error(`unexpected command ${args.join(" ")}`);
91
+ };
92
+
93
+ const controller = createGcloudConfigCopyController({}, store, runner);
94
+ const runtime = await controller.runtime({} as never);
95
+ const credentials = await runtime.freshAccessToken();
96
+
97
+ expect(credentials.accessToken).toBe("access-token");
98
+ expect(credentials.account).toBe("dev@example.com");
99
+ expect(credentials.tokenType).toBe("Bearer");
100
+ expect(Date.parse(credentials.expiresAt)).toBeGreaterThan(Date.now());
101
+ expect(store.getCredentials()?.accessToken).toBe("access-token");
102
+ expect(calls).toEqual([
103
+ ["auth", "print-access-token", "--quiet"],
104
+ ["config", "get-value", "account", "--quiet"],
105
+ ]);
106
+ });
107
+
108
+ test("copies local gcloud config files needed for auth", async () => {
109
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-gcloud-"));
110
+ const configDir = join(projectDir, "gcloud");
111
+ mkdirSync(join(configDir, "configurations"), { recursive: true });
112
+ mkdirSync(join(configDir, "logs"), { recursive: true });
113
+ mkdirSync(join(configDir, "virtenv", "bin"), { recursive: true });
114
+ writeFileSync(join(configDir, "active_config"), "default\n");
115
+ writeFileSync(join(configDir, "access_tokens.db"), "access");
116
+ writeFileSync(join(configDir, "credentials.db"), "credentials");
117
+ writeFileSync(join(configDir, "default_configs.db"), "not copied config");
118
+ writeFileSync(join(configDir, "configurations", "config_default"), "[core]\naccount = dev@example.com\n");
119
+ writeFileSync(join(configDir, "logs", "ignored.log"), "noise");
120
+ writeFileSync(join(configDir, "virtenv", "bin", "python"), "not copied config");
121
+
122
+ const state = createStateStore({ projectDir });
123
+ await state.syncSchema();
124
+ const store = createGcloudAuthStore(state.providerStorage("gcloud.config.copy"));
125
+ const calls: string[][] = [];
126
+ const runner: GcloudCommandRunner = async (_command, args) => {
127
+ calls.push([...args]);
128
+ if (args[0] === "config") return { stdout: "dev@example.com\n", stderr: "", exitCode: 0 };
129
+ if (args[0] === "info") return { stdout: `${configDir}\n`, stderr: "", exitCode: 0 };
130
+ throw new Error(`unexpected command ${args.join(" ")}`);
131
+ };
132
+
133
+ const controller = createGcloudConfigCopyController({}, store, runner);
134
+ const runtime = await controller.runtime({} as never);
135
+ const configCopy = await runtime.configFiles();
136
+
137
+ expect(configCopy.account).toBe("dev@example.com");
138
+ expect(configCopy.sourceConfigDir).toBe(configDir);
139
+ expect(configCopy.files.map((file) => file.path)).toEqual([
140
+ "access_tokens.db",
141
+ "active_config",
142
+ "configurations/config_default",
143
+ "credentials.db",
144
+ ]);
145
+ expect(Buffer.from(configCopy.files[3]!.contentsBase64, "base64").toString()).toBe("credentials");
146
+ expect(calls).toEqual([
147
+ ["config", "get-value", "account", "--quiet"],
148
+ ["info", "--format=value(config.paths.global_config_dir)", "--quiet"],
149
+ ]);
150
+ });
151
+ });
@@ -0,0 +1,312 @@
1
+ import type { WorkflowProviderController } from "@rigkit/engine";
2
+ import { homedir } from "node:os";
3
+ import { join, relative, resolve, sep } from "node:path";
4
+ import { lstat, readdir, readFile } from "node:fs/promises";
5
+ import type { GcloudAccessCredentials, GcloudConfigCopy } from "./inject.ts";
6
+ import {
7
+ DEFAULT_GCLOUD_CREDENTIAL_KEY,
8
+ normalizeScopes,
9
+ type GcloudStoredCredentials,
10
+ } from "./store.ts";
11
+ import type { createGcloudAuthStore } from "./store.ts";
12
+
13
+ export const GCLOUD_CONFIG_COPY_PROVIDER_ID = "gcloud-config-copy";
14
+
15
+ export const DEFAULT_GCLOUD_AUTH_SCOPES = [
16
+ "email",
17
+ "openid",
18
+ "https://www.googleapis.com/auth/cloud-platform",
19
+ ] as const;
20
+
21
+ export const DEFAULT_GCLOUD_INSTALL_URL = "https://cloud.google.com/sdk/docs/install";
22
+
23
+ const DEFAULT_ACCESS_TOKEN_LIFETIME_SECONDS = 55 * 60;
24
+
25
+ export type GcloudConfigCopyConfig = {
26
+ command?: string;
27
+ requireAuth?: boolean;
28
+ key?: string;
29
+ account?: string;
30
+ scopes?: readonly string[];
31
+ installUrl?: string;
32
+ accessTokenLifetimeSeconds?: number;
33
+ configDir?: string;
34
+ };
35
+
36
+ export type GcloudFreshAccessTokenOptions = {
37
+ key?: string;
38
+ account?: string;
39
+ scopes?: readonly string[];
40
+ };
41
+
42
+ export type GcloudConfigFilesOptions = {
43
+ account?: string;
44
+ configDir?: string;
45
+ };
46
+
47
+ export type GcloudConfigCopyRuntime = {
48
+ freshAccessToken(options?: GcloudFreshAccessTokenOptions): Promise<GcloudAccessCredentials>;
49
+ configFiles(options?: GcloudConfigFilesOptions): Promise<GcloudConfigCopy>;
50
+ };
51
+
52
+ export type GcloudCommandResult = {
53
+ stdout: string;
54
+ stderr: string;
55
+ exitCode: number;
56
+ };
57
+
58
+ export type GcloudCommandRunner = (command: string, args: readonly string[]) => Promise<GcloudCommandResult>;
59
+
60
+ type GcloudAuthStore = ReturnType<typeof createGcloudAuthStore>;
61
+
62
+ export function createGcloudConfigCopyController(
63
+ config: GcloudConfigCopyConfig,
64
+ store: GcloudAuthStore,
65
+ runner: GcloudCommandRunner = runGcloudCommand,
66
+ ): WorkflowProviderController<GcloudConfigCopyRuntime> {
67
+ return {
68
+ providerId: GCLOUD_CONFIG_COPY_PROVIDER_ID,
69
+ runtime() {
70
+ return {
71
+ freshAccessToken: async (options = {}) =>
72
+ await freshAccessToken({
73
+ config,
74
+ store,
75
+ runner,
76
+ options,
77
+ }),
78
+ configFiles: async (options = {}) =>
79
+ await localGcloudConfigFiles({
80
+ config,
81
+ runner,
82
+ options,
83
+ }),
84
+ };
85
+ },
86
+ };
87
+ }
88
+
89
+ export async function assertLocalGcloudReady(
90
+ config: GcloudConfigCopyConfig,
91
+ runner: GcloudCommandRunner = runGcloudCommand,
92
+ ): Promise<void> {
93
+ const command = config.command ?? "gcloud";
94
+ const version = await runner(command, ["--version"]);
95
+ if (version.exitCode !== 0) {
96
+ throw new Error(
97
+ [
98
+ `Local gcloud CLI is required for this workflow, but ${JSON.stringify(command)} could not be run.`,
99
+ `Install it from ${config.installUrl ?? DEFAULT_GCLOUD_INSTALL_URL}, then rerun Rigkit.`,
100
+ version.stderr || version.stdout,
101
+ ].filter(Boolean).join("\n"),
102
+ );
103
+ }
104
+
105
+ const token = await runner(command, authPrintAccessTokenArgs({ account: config.account }));
106
+ if (token.exitCode !== 0 || !token.stdout.trim()) {
107
+ throw new Error(
108
+ [
109
+ "Local gcloud is installed, but it is not authenticated.",
110
+ "Run `gcloud auth login`, then rerun Rigkit.",
111
+ token.stderr || token.stdout,
112
+ ].filter(Boolean).join("\n"),
113
+ );
114
+ }
115
+ }
116
+
117
+ export function requiresLocalGcloudAuth(config: GcloudConfigCopyConfig): boolean {
118
+ return config.requireAuth ?? true;
119
+ }
120
+
121
+ async function freshAccessToken(input: {
122
+ config: GcloudConfigCopyConfig;
123
+ store: GcloudAuthStore;
124
+ runner: GcloudCommandRunner;
125
+ options: GcloudFreshAccessTokenOptions;
126
+ }): Promise<GcloudAccessCredentials> {
127
+ const command = input.config.command ?? "gcloud";
128
+ const account = input.options.account ?? input.config.account;
129
+ const token = await input.runner(command, authPrintAccessTokenArgs({ account }));
130
+ if (token.exitCode !== 0 || !token.stdout.trim()) {
131
+ throw new Error(
132
+ [
133
+ "Failed to mint a fresh gcloud access token from local gcloud.",
134
+ "Run `gcloud auth login`, then rerun Rigkit.",
135
+ token.stderr || token.stdout,
136
+ ].filter(Boolean).join("\n"),
137
+ );
138
+ }
139
+
140
+ const configuredAccount = account ?? await readConfiguredAccount(command, input.runner);
141
+ const lifetimeSeconds = input.config.accessTokenLifetimeSeconds ?? DEFAULT_ACCESS_TOKEN_LIFETIME_SECONDS;
142
+ const credentials = input.store.saveCredentials({
143
+ key: input.options.key ?? input.config.key ?? DEFAULT_GCLOUD_CREDENTIAL_KEY,
144
+ account: configuredAccount,
145
+ scopes: normalizeScopes(input.options.scopes ?? input.config.scopes ?? DEFAULT_GCLOUD_AUTH_SCOPES),
146
+ accessToken: token.stdout.trim(),
147
+ tokenType: "Bearer",
148
+ expiresAt: new Date(Date.now() + lifetimeSeconds * 1000).toISOString(),
149
+ });
150
+
151
+ return toAccessCredentials(credentials);
152
+ }
153
+
154
+ async function localGcloudConfigFiles(input: {
155
+ config: GcloudConfigCopyConfig;
156
+ runner: GcloudCommandRunner;
157
+ options: GcloudConfigFilesOptions;
158
+ }): Promise<GcloudConfigCopy> {
159
+ const command = input.config.command ?? "gcloud";
160
+ const account = input.options.account ?? input.config.account ?? await readConfiguredAccount(command, input.runner);
161
+ const configDir = resolveGcloudConfigDir(
162
+ input.options.configDir ??
163
+ input.config.configDir ??
164
+ await readConfiguredGcloudConfigDir(command, input.runner),
165
+ );
166
+ const files = await readGcloudConfigFiles(configDir);
167
+ if (files.length === 0) {
168
+ throw new Error(`No copyable gcloud config files found in ${configDir}`);
169
+ }
170
+
171
+ return {
172
+ sourceConfigDir: configDir,
173
+ account,
174
+ files,
175
+ };
176
+ }
177
+
178
+ async function readConfiguredAccount(
179
+ command: string,
180
+ runner: GcloudCommandRunner,
181
+ ): Promise<string | undefined> {
182
+ const account = await runner(command, ["config", "get-value", "account", "--quiet"]);
183
+ if (account.exitCode !== 0) return undefined;
184
+ const value = account.stdout.trim();
185
+ return value && value !== "(unset)" ? value : undefined;
186
+ }
187
+
188
+ async function readConfiguredGcloudConfigDir(
189
+ command: string,
190
+ runner: GcloudCommandRunner,
191
+ ): Promise<string | undefined> {
192
+ const result = await runner(command, ["info", "--format=value(config.paths.global_config_dir)", "--quiet"]);
193
+ const value = result.stdout.trim();
194
+ return result.exitCode === 0 && value ? value : undefined;
195
+ }
196
+
197
+ async function readGcloudConfigFiles(configDir: string): Promise<GcloudConfigCopy["files"]> {
198
+ const root = resolve(configDir);
199
+ const files: GcloudConfigCopy["files"] = [];
200
+
201
+ async function visit(path: string): Promise<void> {
202
+ const entry = await lstat(path);
203
+ if (entry.isSymbolicLink()) return;
204
+ if (entry.isDirectory()) {
205
+ const name = path.split(sep).at(-1);
206
+ if (name && shouldSkipConfigDir(name)) return;
207
+ const children = await readdir(path);
208
+ await Promise.all(children.map((child) => visit(join(path, child))));
209
+ return;
210
+ }
211
+ if (!entry.isFile()) return;
212
+
213
+ const relativePath = relative(root, path).split(sep).join("/");
214
+ if (!isSafeRelativePath(relativePath)) return;
215
+ if (!shouldCopyConfigFile(relativePath)) return;
216
+
217
+ files.push({
218
+ path: relativePath,
219
+ contentsBase64: Buffer.from(await readFile(path)).toString("base64"),
220
+ });
221
+ }
222
+
223
+ await visit(root);
224
+ return files.sort((left, right) => left.path.localeCompare(right.path));
225
+ }
226
+
227
+ function resolveGcloudConfigDir(configDir: string | undefined): string {
228
+ if (configDir?.trim()) return resolve(expandHome(configDir.trim()));
229
+ if (process.env.CLOUDSDK_CONFIG?.trim()) return resolve(expandHome(process.env.CLOUDSDK_CONFIG.trim()));
230
+ return join(homedir(), ".config", "gcloud");
231
+ }
232
+
233
+ function expandHome(path: string): string {
234
+ if (path === "~") return homedir();
235
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
236
+ return path;
237
+ }
238
+
239
+ function shouldSkipConfigDir(name: string): boolean {
240
+ return (
241
+ name === "logs" ||
242
+ name === "cache" ||
243
+ name === "virtenv" ||
244
+ name === ".install" ||
245
+ name === ".backup" ||
246
+ name === "__pycache__"
247
+ );
248
+ }
249
+
250
+ function shouldCopyConfigFile(path: string): boolean {
251
+ return (
252
+ path === "active_config" ||
253
+ path === "application_default_credentials.json" ||
254
+ path === "access_tokens.db" ||
255
+ path.startsWith("access_tokens.db-") ||
256
+ path === "credentials.db" ||
257
+ path.startsWith("credentials.db-") ||
258
+ path.startsWith("configurations/") ||
259
+ path.startsWith("legacy_credentials/")
260
+ );
261
+ }
262
+
263
+ function isSafeRelativePath(path: string): boolean {
264
+ return Boolean(
265
+ path &&
266
+ !path.startsWith("/") &&
267
+ !path.includes("\0") &&
268
+ !path.includes("\n") &&
269
+ !path.includes("\r") &&
270
+ path.split("/").every((part) => part && part !== "." && part !== "..")
271
+ );
272
+ }
273
+
274
+ function authPrintAccessTokenArgs(input: { account?: string }): string[] {
275
+ return [
276
+ "auth",
277
+ "print-access-token",
278
+ "--quiet",
279
+ ...(input.account ? ["--account", input.account] : []),
280
+ ];
281
+ }
282
+
283
+ async function runGcloudCommand(command: string, args: readonly string[]): Promise<GcloudCommandResult> {
284
+ try {
285
+ const proc = Bun.spawn([command, ...args], {
286
+ stdout: "pipe",
287
+ stderr: "pipe",
288
+ });
289
+ const [stdout, stderr, exitCode] = await Promise.all([
290
+ new Response(proc.stdout).text(),
291
+ new Response(proc.stderr).text(),
292
+ proc.exited,
293
+ ]);
294
+ return { stdout, stderr, exitCode };
295
+ } catch (error) {
296
+ return {
297
+ stdout: "",
298
+ stderr: error instanceof Error ? error.message : String(error),
299
+ exitCode: 127,
300
+ };
301
+ }
302
+ }
303
+
304
+ function toAccessCredentials(credentials: GcloudStoredCredentials): GcloudAccessCredentials {
305
+ return {
306
+ accessToken: credentials.accessToken,
307
+ tokenType: credentials.tokenType,
308
+ expiresAt: credentials.expiresAt,
309
+ account: credentials.account,
310
+ scopes: credentials.scopes,
311
+ };
312
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createStateStore } from "@rigkit/engine";
6
+ import { createGcloudAuthStore } from "./store.ts";
7
+
8
+ describe("gcloud auth store", () => {
9
+ test("upserts local access token credentials", async () => {
10
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-gcloud-"));
11
+ const state = createStateStore({ projectDir });
12
+ await state.syncSchema();
13
+ const store = createGcloudAuthStore(state.providerStorage("gcloud.config.copy"));
14
+
15
+ const first = store.saveCredentials({
16
+ account: "dev@example.com",
17
+ scopes: ["openid", "https://www.googleapis.com/auth/cloud-platform"],
18
+ accessToken: "access-1",
19
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
20
+ });
21
+
22
+ const second = store.saveCredentials({
23
+ scopes: ["https://www.googleapis.com/auth/cloud-platform", "openid"],
24
+ accessToken: "access-2",
25
+ expiresAt: new Date(Date.now() + 120_000).toISOString(),
26
+ });
27
+
28
+ expect(second.id).toBe(first.id);
29
+ expect(second.account).toBe("dev@example.com");
30
+ expect(second.accessToken).toBe("access-2");
31
+ expect(second.scopes).toEqual(["https://www.googleapis.com/auth/cloud-platform", "openid"]);
32
+ expect(store.getCredentials()?.accessToken).toBe("access-2");
33
+ });
34
+ });
package/src/store.ts ADDED
@@ -0,0 +1,99 @@
1
+ import type { ProviderStorage } from "@rigkit/engine";
2
+ import type { JsonValue } from "@rigkit/sdk";
3
+
4
+ export const DEFAULT_GCLOUD_CREDENTIAL_KEY = "default";
5
+
6
+ export type GcloudStoredCredentials = {
7
+ id: string;
8
+ key: string;
9
+ account?: string | null;
10
+ scopes: string[];
11
+ accessToken: string;
12
+ tokenType: string;
13
+ expiresAt: string;
14
+ createdAt: string;
15
+ updatedAt: string;
16
+ };
17
+
18
+ export type GcloudCredentialsInput = {
19
+ key?: string;
20
+ account?: string | null;
21
+ scopes: string[];
22
+ accessToken: string;
23
+ tokenType?: string;
24
+ expiresAt: string;
25
+ };
26
+
27
+ export function createGcloudAuthStore(storage: ProviderStorage) {
28
+ return {
29
+ getCredentials(key = DEFAULT_GCLOUD_CREDENTIAL_KEY): GcloudStoredCredentials | undefined {
30
+ return parseCredentials(storage.get(credentialsKey(key))?.value);
31
+ },
32
+
33
+ saveCredentials(input: GcloudCredentialsInput): GcloudStoredCredentials {
34
+ const now = new Date().toISOString();
35
+ const key = input.key ?? DEFAULT_GCLOUD_CREDENTIAL_KEY;
36
+ const existing = this.getCredentials(key);
37
+ const credentials: GcloudStoredCredentials = {
38
+ id: existing?.id ?? crypto.randomUUID(),
39
+ key,
40
+ account: input.account ?? existing?.account ?? null,
41
+ scopes: normalizeScopes(input.scopes),
42
+ accessToken: input.accessToken,
43
+ tokenType: input.tokenType ?? "Bearer",
44
+ expiresAt: input.expiresAt,
45
+ createdAt: existing?.createdAt ?? now,
46
+ updatedAt: now,
47
+ };
48
+
49
+ storage.set(credentialsKey(key), credentials as unknown as JsonValue);
50
+ return credentials;
51
+ },
52
+ };
53
+ }
54
+
55
+ export function normalizeScopes(scopes: readonly string[]): string[] {
56
+ return [...new Set(scopes.map((scope) => scope.trim()).filter(Boolean))].sort();
57
+ }
58
+
59
+ function credentialsKey(key: string): string {
60
+ return `credentials:${key}`;
61
+ }
62
+
63
+ function parseCredentials(value: JsonValue | undefined): GcloudStoredCredentials | undefined {
64
+ if (!isRecord(value)) return undefined;
65
+ return {
66
+ id: requiredString(value, "id"),
67
+ key: requiredString(value, "key"),
68
+ account: optionalStringOrNull(value, "account"),
69
+ scopes: stringArray(value.scopes, "scopes"),
70
+ accessToken: requiredString(value, "accessToken"),
71
+ tokenType: requiredString(value, "tokenType"),
72
+ expiresAt: requiredString(value, "expiresAt"),
73
+ createdAt: requiredString(value, "createdAt"),
74
+ updatedAt: requiredString(value, "updatedAt"),
75
+ };
76
+ }
77
+
78
+ function requiredString(record: Record<string, JsonValue>, key: string): string {
79
+ const value = record[key];
80
+ if (typeof value !== "string" || value.trim() === "") {
81
+ throw new Error(`Invalid gcloud provider state: ${key} must be a non-empty string`);
82
+ }
83
+ return value;
84
+ }
85
+
86
+ function optionalStringOrNull(record: Record<string, JsonValue>, key: string): string | null | undefined {
87
+ const value = record[key];
88
+ if (value === undefined || value === null || typeof value === "string") return value;
89
+ throw new Error(`Invalid gcloud provider state: ${key} must be a string or null`);
90
+ }
91
+
92
+ function stringArray(value: JsonValue | undefined, key: string): string[] {
93
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value;
94
+ throw new Error(`Invalid gcloud provider state: ${key} must be a string array`);
95
+ }
96
+
97
+ function isRecord(value: unknown): value is Record<string, JsonValue> {
98
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
99
+ }
package/src/version.ts ADDED
@@ -0,0 +1 @@
1
+ export const RIGKIT_PROVIDER_GCLOUD_CLI_VERSION = "0.0.0-canary-20260518T014918-c5bc0c2";