Skip to content

Commit b147c6a

Browse files
committed
[release] Fix function deploy params
1 parent d26d64a commit b147c6a

2 files changed

Lines changed: 551 additions & 13 deletions

File tree

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
import { isAbsolute, resolve as resolvePath, dirname, join } from "node:path";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import { Client, Functions } from "node-appwrite";
5+
import {
6+
MessageFormatter,
7+
resolveCliCredentials,
8+
getClientWithAuth,
9+
findYamlConfig,
10+
} from "appwrite-utils-helpers";
11+
import type { AppwriteFunction } from "appwrite-utils";
12+
import {
13+
discoverFnConfigs,
14+
mergeDiscoveredFunctions,
15+
} from "../../functions/fnConfigDiscovery.js";
16+
import {
17+
deployFunctionsBatch,
18+
type BatchDeployItem,
19+
} from "../../functions/batchDeploy.js";
20+
import type { UtilsController } from "../../utilsController.js";
21+
22+
/** Per-field CLI overrides — only applied when targeting a single function. */
23+
export interface DeployFunctionFieldOverrides {
24+
path?: string;
25+
name?: string;
26+
runtime?: string;
27+
entrypoint?: string;
28+
commands?: string;
29+
schedule?: string;
30+
timeout?: number;
31+
scopes?: string;
32+
events?: string;
33+
execute?: string;
34+
enabled?: boolean;
35+
logging?: boolean;
36+
buildSpecification?: string;
37+
runtimeSpecification?: string;
38+
predeployCommands?: string;
39+
deployDir?: string;
40+
ignore?: string;
41+
}
42+
43+
export interface DeployFunctionsFlowOptions {
44+
cwd: string;
45+
configPath?: string;
46+
controller?: UtilsController;
47+
/** From --functionIds (comma-separated $ids or names). */
48+
functionIds?: string;
49+
/** From --functionId (singular, the existing flag). */
50+
singleFunctionId?: string;
51+
/** From --buildConcurrency. */
52+
buildConcurrency?: number;
53+
/** From --endpoint/--projectId/--apiKey/--sessionCookie on argv. */
54+
argvCredentials?: {
55+
endpoint?: string;
56+
projectId?: string;
57+
apiKey?: string;
58+
sessionCookie?: string;
59+
};
60+
/** Per-field overrides; only valid when exactly one function is targeted. */
61+
overrides?: DeployFunctionFieldOverrides;
62+
}
63+
64+
function expandTilde(p: string): string {
65+
if (!p) return p;
66+
if (p === "~" || p.startsWith("~/")) {
67+
return p.replace(/^~(?=$|\/|\\)/, os.homedir());
68+
}
69+
return p;
70+
}
71+
72+
function splitCsv(value: string | undefined): string[] {
73+
if (!value) return [];
74+
return value.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
75+
}
76+
77+
/**
78+
* Mirror of `functionCommands.deployFunction`'s priority chain — find the
79+
* source directory on disk given a (possibly partial) function config.
80+
*/
81+
function resolveFunctionSourceDirectory(
82+
fn: AppwriteFunction,
83+
yamlBaseDir: string,
84+
appwriteFolderPath: string | undefined,
85+
cwd: string
86+
): string | null {
87+
const nameLower = fn.name.toLowerCase().replace(/\s+/g, "-");
88+
89+
const candidates: string[] = [];
90+
91+
if (fn.dirPath) {
92+
const expanded = expandTilde(fn.dirPath);
93+
candidates.push(
94+
isAbsolute(expanded) ? expanded : resolvePath(yamlBaseDir, expanded)
95+
);
96+
}
97+
if (appwriteFolderPath) {
98+
candidates.push(join(appwriteFolderPath, "functions", nameLower));
99+
candidates.push(join(appwriteFolderPath, "functions", fn.name));
100+
}
101+
candidates.push(join(cwd, "functions", nameLower));
102+
candidates.push(join(cwd, "functions", fn.name));
103+
candidates.push(join(cwd, nameLower));
104+
candidates.push(join(cwd, fn.name));
105+
106+
for (const candidate of candidates) {
107+
if (fs.existsSync(candidate)) return candidate;
108+
}
109+
return null;
110+
}
111+
112+
/**
113+
* Build an `AppwriteFunction` from the SDK `functions.get` response so we can
114+
* deploy code against an existing function $id that isn't in any local config.
115+
*/
116+
async function synthesizeFromServer(
117+
client: Client,
118+
functionId: string
119+
): Promise<AppwriteFunction | null> {
120+
try {
121+
const functions = new Functions(client);
122+
const remote = (await functions.get(functionId)) as any;
123+
const fn: AppwriteFunction = {
124+
$id: remote.$id,
125+
name: remote.name,
126+
runtime: remote.runtime,
127+
execute: remote.execute ?? [],
128+
events: remote.events ?? [],
129+
schedule: remote.schedule,
130+
timeout: remote.timeout,
131+
enabled: remote.enabled,
132+
logging: remote.logging,
133+
entrypoint: remote.entrypoint,
134+
commands: remote.commands,
135+
scopes: remote.scopes ?? [],
136+
installationId: remote.installationId,
137+
providerRepositoryId: remote.providerRepositoryId,
138+
providerBranch: remote.providerBranch,
139+
providerSilentMode: remote.providerSilentMode,
140+
providerRootDirectory: remote.providerRootDirectory,
141+
buildSpecification: remote.buildSpecification,
142+
runtimeSpecification: remote.runtimeSpecification,
143+
};
144+
return fn;
145+
} catch (err: any) {
146+
const code = err?.code ?? err?.response?.code;
147+
if (code === 404) return null;
148+
throw err;
149+
}
150+
}
151+
152+
function applyOverrides(
153+
base: AppwriteFunction,
154+
overrides: DeployFunctionFieldOverrides
155+
): AppwriteFunction {
156+
const next: AppwriteFunction = { ...base };
157+
if (overrides.path !== undefined) next.dirPath = expandTilde(overrides.path);
158+
if (overrides.name !== undefined) next.name = overrides.name;
159+
if (overrides.runtime !== undefined) next.runtime = overrides.runtime as any;
160+
if (overrides.entrypoint !== undefined) next.entrypoint = overrides.entrypoint;
161+
if (overrides.commands !== undefined) next.commands = overrides.commands;
162+
if (overrides.schedule !== undefined) next.schedule = overrides.schedule;
163+
if (overrides.timeout !== undefined) next.timeout = overrides.timeout;
164+
if (overrides.scopes !== undefined) {
165+
next.scopes = splitCsv(overrides.scopes) as any;
166+
}
167+
if (overrides.events !== undefined) {
168+
next.events = splitCsv(overrides.events) as any;
169+
}
170+
if (overrides.execute !== undefined) {
171+
next.execute = splitCsv(overrides.execute);
172+
}
173+
if (overrides.enabled !== undefined) next.enabled = overrides.enabled;
174+
if (overrides.logging !== undefined) next.logging = overrides.logging;
175+
if (overrides.buildSpecification !== undefined) {
176+
next.buildSpecification = overrides.buildSpecification as any;
177+
}
178+
if (overrides.runtimeSpecification !== undefined) {
179+
next.runtimeSpecification = overrides.runtimeSpecification as any;
180+
}
181+
if (overrides.predeployCommands !== undefined) {
182+
next.predeployCommands = splitCsv(overrides.predeployCommands);
183+
}
184+
if (overrides.deployDir !== undefined) next.deployDir = overrides.deployDir;
185+
if (overrides.ignore !== undefined) next.ignore = splitCsv(overrides.ignore);
186+
return next;
187+
}
188+
189+
function anyOverrideSet(o: DeployFunctionFieldOverrides | undefined): boolean {
190+
if (!o) return false;
191+
return Object.values(o).some((v) => v !== undefined);
192+
}
193+
194+
/**
195+
* Headless function deploy entry point. Returns the number of failures so the
196+
* caller can set a non-zero exit code.
197+
*/
198+
export async function runDeployFunctionsFlow(
199+
opts: DeployFunctionsFlowOptions
200+
): Promise<number> {
201+
const cwd = opts.cwd;
202+
203+
// 1. Build SDK client. Prefer the controller's already-wired client; fall
204+
// back to building one from argv credentials (the bare-credentials path).
205+
let client: Client | undefined = opts.controller?.appwriteServer;
206+
if (!client) {
207+
const creds = resolveCliCredentials({ argv: opts.argvCredentials });
208+
if (!creds) {
209+
throw new Error(
210+
"--deployFunctions: no Appwrite credentials found. Pass --endpoint, " +
211+
"--projectId, and --apiKey (or set APPWRITE_ENDPOINT/APPWRITE_PROJECT_ID/" +
212+
"APPWRITE_API_KEY), or run from a directory with a configured Appwrite sidecar."
213+
);
214+
}
215+
client = getClientWithAuth(
216+
creds.endpoint,
217+
creds.projectId!,
218+
creds.apiKey,
219+
opts.argvCredentials?.sessionCookie
220+
);
221+
}
222+
223+
// 2. Discover functions: .fnconfig.yaml files + central config.yaml functions[]
224+
let discovered: AppwriteFunction[] = [];
225+
try {
226+
discovered = discoverFnConfigs(cwd);
227+
} catch (e) {
228+
MessageFormatter.warning(
229+
`--deployFunctions: .fnconfig discovery failed: ${e instanceof Error ? e.message : String(e)}`,
230+
{ prefix: "Functions" }
231+
);
232+
}
233+
const central: AppwriteFunction[] =
234+
(opts.controller?.config?.functions as AppwriteFunction[] | undefined) ?? [];
235+
let merged = mergeDiscoveredFunctions(central, discovered);
236+
237+
// 3. Resolve target ids: --functionIds wins, then --functionId.
238+
const tokens =
239+
splitCsv(opts.functionIds).length > 0
240+
? splitCsv(opts.functionIds)
241+
: opts.singleFunctionId
242+
? [opts.singleFunctionId]
243+
: [];
244+
245+
// 4. Build the selected[] set, falling back to functions.get(server) when a
246+
// requested $id has no local declaration.
247+
const selected: AppwriteFunction[] = [];
248+
if (tokens.length === 0) {
249+
if (merged.length === 0) {
250+
throw new Error(
251+
"--deployFunctions: no functions found. Provide a .fnconfig.yaml in/under " +
252+
"the cwd, a config.yaml with functions[], or --functionId + --functionPath " +
253+
"for an ad-hoc deploy."
254+
);
255+
}
256+
selected.push(...merged);
257+
} else {
258+
for (const token of tokens) {
259+
const local = merged.find((f) => f.$id === token || f.name === token);
260+
if (local) {
261+
selected.push(local);
262+
continue;
263+
}
264+
// Try server fallback — only meaningful when token looks like an $id.
265+
const fromServer = await synthesizeFromServer(client, token);
266+
if (!fromServer) {
267+
throw new Error(
268+
`Function not found: ${token}. Pass --functionId of an existing ` +
269+
`Appwrite function or declare it in .fnconfig.yaml/config.yaml. ` +
270+
`Available local: ${
271+
merged.map((f) => f.$id).join(", ") || "<none>"
272+
}`
273+
);
274+
}
275+
MessageFormatter.info(
276+
`Using server-side definition for '${fromServer.name}' (${fromServer.$id}) — no local declaration found.`,
277+
{ prefix: "Functions" }
278+
);
279+
selected.push(fromServer);
280+
}
281+
}
282+
283+
// 5. Apply per-field overrides — single-function only.
284+
if (anyOverrideSet(opts.overrides)) {
285+
if (selected.length !== 1) {
286+
throw new Error(
287+
"Per-field overrides (--functionRuntime/--functionEntrypoint/etc.) only " +
288+
"valid when --deployFunctions targets exactly one function. Got " +
289+
`${selected.length} targets.`
290+
);
291+
}
292+
selected[0] = applyOverrides(selected[0]!, opts.overrides!);
293+
}
294+
295+
// 6. Resolve source directories and build BatchDeployItem[].
296+
const yamlConfigPath = opts.configPath
297+
? isAbsolute(opts.configPath)
298+
? opts.configPath
299+
: resolvePath(cwd, opts.configPath)
300+
: findYamlConfig(cwd);
301+
const yamlBaseDir = yamlConfigPath ? dirname(yamlConfigPath) : cwd;
302+
const appwriteFolderPath = opts.controller?.getAppwriteFolderPath();
303+
304+
const items: BatchDeployItem[] = [];
305+
const skipped: string[] = [];
306+
for (const fn of selected) {
307+
const dir = resolveFunctionSourceDirectory(
308+
fn,
309+
yamlBaseDir,
310+
appwriteFolderPath,
311+
cwd
312+
);
313+
if (!dir) {
314+
MessageFormatter.warning(
315+
`Function "${fn.name}" (${fn.$id}) skipped: source directory not found. ` +
316+
`Set 'dirPath' in .fnconfig.yaml, place source under <cwd>/functions/<name>/, ` +
317+
`or pass --functionPath.`,
318+
{ prefix: "Functions" }
319+
);
320+
skipped.push(fn.name);
321+
continue;
322+
}
323+
items.push({
324+
functionName: fn.name,
325+
functionConfig: { ...fn, dirPath: dir },
326+
functionPath: dir,
327+
configDirPath: yamlBaseDir,
328+
});
329+
}
330+
331+
if (items.length === 0) {
332+
throw new Error(
333+
`--deployFunctions: nothing to deploy. ${
334+
skipped.length > 0
335+
? `Skipped (source not found): ${skipped.join(", ")}.`
336+
: ""
337+
}`
338+
);
339+
}
340+
341+
// 7. Run batch deploy. Exit code is computed from results.
342+
const results = await deployFunctionsBatch(client, items, {
343+
buildConcurrency: opts.buildConcurrency,
344+
});
345+
return results.filter((r) => r.status === "failed").length + skipped.length;
346+
}

0 commit comments

Comments
 (0)