Skip to content

Commit 002869e

Browse files
feat: auto create credentials.yaml (#1340)
* feat: auto create credentials.yaml * chore: update changelog * chore: resolve changes * chore: remove .trim() * chore: update changelog * remove invalid changeset --------- Co-authored-by: Joe Clark <jclark@openfn.org>
1 parent b0261ef commit 002869e

File tree

6 files changed

+200
-10
lines changed

6 files changed

+200
-10
lines changed

.changeset/odd-fans-taste.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openfn/cli': minor
3+
---
4+
5+
Auto generates credentials.yaml for pulled projects

packages/cli/src/execute/handler.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import type { ExecutionPlan } from '@openfn/lexicon';
2-
import { yamlToJson } from '@openfn/project';
3-
import { readFile } from 'node:fs/promises';
42
import path from 'node:path';
53

64
import type { ExecuteOptions } from './command';
@@ -22,6 +20,7 @@ import fuzzyMatchStep from '../util/fuzzy-match-step';
2220
import abort from '../util/abort';
2321
import validatePlan from '../util/validate-plan';
2422
import overridePlanAdaptors from '../util/override-plan-adaptors';
23+
import { loadCredentialMap } from '../util/load-credential-map';
2524

2625
const matchStep = (
2726
plan: ExecutionPlan,
@@ -56,15 +55,9 @@ const loadAndApplyCredentialMap = async (
5655
let creds = {};
5756
if (options.credentials) {
5857
try {
59-
const credsRaw = await readFile(
60-
path.resolve(options.workspace!, options.credentials),
61-
'utf8'
58+
creds = loadCredentialMap(
59+
path.resolve(options.workspace!, options.credentials)
6260
);
63-
if (options.credentials.endsWith('.json')) {
64-
creds = JSON.parse(credsRaw);
65-
} else {
66-
creds = yamlToJson(credsRaw);
67-
}
6861
logger.info('Credential map loaded ');
6962
} catch (e: any) {
7063
// If we get here, the credential map failed to load

packages/cli/src/projects/checkout.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
tidyWorkflowDir,
1616
updateForkedFrom,
1717
} from './util';
18+
import { createProjectCredentials } from './create-credentials';
1819

1920
export type CheckoutOptions = Pick<
2021
Opts,
@@ -125,5 +126,8 @@ export const handler = async (options: CheckoutOptions, logger?: Logger) => {
125126
logger?.warn('WARNING! No content for file', f);
126127
}
127128
}
129+
130+
createProjectCredentials(workspacePath, switchProject, logger);
131+
128132
logger?.success(`Expanded project to ${workspacePath}`);
129133
};
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
import type Project from '@openfn/project';
5+
import { jsonToYaml } from '@openfn/project';
6+
7+
import { loadCredentialMap } from '../util/load-credential-map';
8+
import type { Logger } from '../util/logger';
9+
10+
export function findCredentialIds(project: Project): string[] {
11+
const ids = new Set<string>();
12+
for (const wf of project.workflows) {
13+
for (const step of wf.steps) {
14+
const job = step as { configuration?: string | null };
15+
const { configuration } = job;
16+
if (
17+
typeof configuration === 'string' &&
18+
configuration &&
19+
!configuration.endsWith('.json')
20+
) {
21+
ids.add(configuration);
22+
}
23+
}
24+
}
25+
return Array.from(ids);
26+
}
27+
28+
export function createProjectCredentials(
29+
workspacePath: string,
30+
project: Project,
31+
logger?: Logger
32+
): void {
33+
const credentialsPath = project.config.credentials;
34+
if (typeof credentialsPath !== 'string') return;
35+
36+
const ids = findCredentialIds(project);
37+
if (!ids.length) return;
38+
39+
const absolutePath = path.resolve(workspacePath, credentialsPath);
40+
let existing: Record<string, unknown> = {};
41+
42+
try {
43+
existing = loadCredentialMap(absolutePath);
44+
} catch (e: any) {
45+
// project doesn't have credential
46+
}
47+
48+
const new_creds = ids.filter((id) => !(id in existing)).sort();
49+
if (!new_creds.length) return;
50+
51+
const merged: Record<string, unknown> = { ...existing };
52+
for (const id of new_creds) {
53+
merged[id] = {};
54+
}
55+
56+
const content = credentialsPath.endsWith('.json')
57+
? `${JSON.stringify(merged, null, 2)}`
58+
: jsonToYaml(merged);
59+
60+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
61+
fs.writeFileSync(absolutePath, content, 'utf8');
62+
logger?.debug(`Added ${new_creds.length} credentials to ${credentialsPath}`);
63+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import fs from 'node:fs';
2+
import { yamlToJson } from '@openfn/project';
3+
4+
export function loadCredentialMap(filePath: string): Record<string, unknown> {
5+
const raw = fs.readFileSync(filePath, 'utf8');
6+
if (!raw.trim()) return {};
7+
8+
if (filePath.endsWith('.json')) {
9+
const parsed = JSON.parse(raw) as unknown;
10+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
11+
throw new Error('credential file contains invalid JSON');
12+
}
13+
return parsed as Record<string, unknown>;
14+
} else {
15+
const parsed = yamlToJson(raw) as unknown;
16+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
17+
return parsed as Record<string, unknown>;
18+
} else if (parsed != null) {
19+
throw new Error('credential file contains invalid YAML');
20+
}
21+
return {};
22+
}
23+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import test from 'ava';
2+
import fs from 'node:fs';
3+
import mock from 'mock-fs';
4+
5+
import Project from '@openfn/project';
6+
import { yamlToJson } from '@openfn/project';
7+
8+
import {
9+
findCredentialIds,
10+
createProjectCredentials,
11+
} from '../../src/projects/create-credentials';
12+
13+
test.afterEach(() => {
14+
try {
15+
mock.restore();
16+
} catch {}
17+
});
18+
19+
const baseWorkflow = (steps: any[]) => ({
20+
id: 'wf',
21+
name: 'wf',
22+
history: [],
23+
steps,
24+
});
25+
26+
test('sync-credentials: inline string references', (t) => {
27+
const project = new Project({
28+
id: 'p',
29+
workflows: [
30+
baseWorkflow([
31+
{ id: 'a', configuration: 'owner|cred' },
32+
{ id: 'c', configuration: 'ignored.json' },
33+
{ id: 'd', configuration: '' },
34+
]),
35+
],
36+
} as any);
37+
38+
const ids = findCredentialIds(project);
39+
t.deepEqual(ids, ['owner|cred']);
40+
});
41+
42+
test('sync-credentials: ignores duplicate references', (t) => {
43+
const project = new Project({
44+
id: 'p',
45+
workflows: [
46+
baseWorkflow([{ id: 'a', configuration: 'same' }]),
47+
baseWorkflow([{ id: 'b', configuration: 'same' }]),
48+
],
49+
} as any);
50+
51+
t.deepEqual(findCredentialIds(project), ['same']);
52+
});
53+
54+
test.only('sync-credentials: creates credential yaml file', (t) => {
55+
mock({ '/ws': {} });
56+
57+
const project = new Project(
58+
{
59+
id: 'p',
60+
workflows: [baseWorkflow([{ id: 'j', configuration: 'new-id' }])],
61+
} as any,
62+
{ credentials: 'credentials.yaml' }
63+
);
64+
65+
createProjectCredentials('/ws', project);
66+
67+
t.true(fs.existsSync('/ws/credentials.yaml'));
68+
const doc = yamlToJson(
69+
fs.readFileSync('/ws/credentials.yaml', 'utf8')
70+
) as any;
71+
t.deepEqual(doc, { 'new-id': {} });
72+
});
73+
74+
test('sync-credentials: preserves existing credentials and adds missing ones', (t) => {
75+
mock({
76+
'/ws': {},
77+
'/ws/credentials.yaml': `existing:
78+
password: secret
79+
`,
80+
});
81+
82+
const project = new Project(
83+
{
84+
id: 'p',
85+
workflows: [
86+
baseWorkflow([
87+
{ id: 'j', configuration: 'existing' },
88+
{ id: 'k', configuration: 'brand-new' },
89+
]),
90+
],
91+
} as any,
92+
{ credentials: 'credentials.yaml' }
93+
);
94+
95+
createProjectCredentials('/ws', project);
96+
97+
const doc = yamlToJson(
98+
fs.readFileSync('/ws/credentials.yaml', 'utf8')
99+
) as any;
100+
t.is(doc.existing.password, 'secret');
101+
t.deepEqual(doc['brand-new'], {});
102+
});

0 commit comments

Comments
 (0)