t44 0.2.0-rc.1
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.
Potentially problematic release.
This version of t44 might be problematic. Click here for more details.
- package/LICENSE.md +203 -0
- package/README.md +154 -0
- package/bin/activate +36 -0
- package/bin/activate.ts +30 -0
- package/bin/postinstall.sh +19 -0
- package/bin/shell +27 -0
- package/bin/t44 +27 -0
- package/caps/HomeRegistry.v0.ts +298 -0
- package/caps/OpenApiSchema.v0.ts +192 -0
- package/caps/ProjectDeployment.v0.ts +363 -0
- package/caps/ProjectDevelopment.v0.ts +246 -0
- package/caps/ProjectPublishing.v0.ts +307 -0
- package/caps/ProjectRack.v0.ts +128 -0
- package/caps/WorkspaceCli.v0.ts +391 -0
- package/caps/WorkspaceConfig.v0.ts +626 -0
- package/caps/WorkspaceConfig.yaml +53 -0
- package/caps/WorkspaceConnection.v0.ts +240 -0
- package/caps/WorkspaceEntityConfig.v0.ts +64 -0
- package/caps/WorkspaceEntityFact.v0.ts +193 -0
- package/caps/WorkspaceInfo.v0.ts +554 -0
- package/caps/WorkspaceInit.v0.ts +30 -0
- package/caps/WorkspaceKey.v0.ts +186 -0
- package/caps/WorkspaceProjects.v0.ts +455 -0
- package/caps/WorkspacePrompt.v0.ts +396 -0
- package/caps/WorkspaceShell.sh +39 -0
- package/caps/WorkspaceShell.v0.ts +104 -0
- package/caps/WorkspaceShell.yaml +65 -0
- package/caps/WorkspaceShellCli.v0.ts +109 -0
- package/caps/WorkspaceTest.v0.ts +167 -0
- package/caps/providers/LICENSE.md +8 -0
- package/caps/providers/README.md +2 -0
- package/caps/providers/bunny.net/ProjectDeployment.v0.ts +328 -0
- package/caps/providers/bunny.net/api-pull.v0.test.ts +319 -0
- package/caps/providers/bunny.net/api-pull.v0.ts +161 -0
- package/caps/providers/bunny.net/api-storage.v0.test.ts +168 -0
- package/caps/providers/bunny.net/api-storage.v0.ts +245 -0
- package/caps/providers/bunny.net/api.v0.ts +95 -0
- package/caps/providers/dynadot.com/ProjectDeployment.v0.ts +207 -0
- package/caps/providers/dynadot.com/api-domains.v0.test.ts +147 -0
- package/caps/providers/dynadot.com/api-domains.v0.ts +137 -0
- package/caps/providers/dynadot.com/api.v0.ts +88 -0
- package/caps/providers/git-scm.com/ProjectPublishing.v0.ts +231 -0
- package/caps/providers/github.com/ProjectPublishing.v0.ts +75 -0
- package/caps/providers/github.com/api.v0.ts +90 -0
- package/caps/providers/npmjs.com/ProjectPublishing.v0.ts +741 -0
- package/caps/providers/vercel.com/ProjectDeployment.v0.ts +339 -0
- package/caps/providers/vercel.com/api.v0.test.ts +67 -0
- package/caps/providers/vercel.com/api.v0.ts +132 -0
- package/caps/providers/vercel.com/bun.lock +194 -0
- package/caps/providers/vercel.com/package.json +10 -0
- package/caps/providers/vercel.com/project.v0.test.ts +108 -0
- package/caps/providers/vercel.com/project.v0.ts +150 -0
- package/caps/providers/vercel.com/tsconfig.json +28 -0
- package/docs/Overview.drawio +189 -0
- package/docs/Overview.svg +4 -0
- package/lib/crypto.ts +53 -0
- package/lib/openapi.ts +132 -0
- package/lib/ucan.ts +137 -0
- package/package.json +41 -0
- package/structs/HomeRegistryConfig.v0.ts +27 -0
- package/structs/ProjectDeploymentConfig.v0.ts +27 -0
- package/structs/ProjectDeploymentFact.v0.ts +110 -0
- package/structs/ProjectPublishingFact.v0.ts +69 -0
- package/structs/ProjectRackConfig.v0.ts +27 -0
- package/structs/WorkspaceCliConfig.v0.ts +27 -0
- package/structs/WorkspaceConfig.v0.ts +27 -0
- package/structs/WorkspaceKeyConfig.v0.ts +27 -0
- package/structs/WorkspaceMappings.v0.ts +27 -0
- package/structs/WorkspaceProjectsConfig.v0.ts +27 -0
- package/structs/WorkspaceRepositories.v0.ts +27 -0
- package/structs/WorkspaceShellConfig.v0.ts +45 -0
- package/structs/providers/LICENSE.md +8 -0
- package/structs/providers/README.md +2 -0
- package/structs/providers/bunny.net/ProjectDeploymentFact.v0.ts +41 -0
- package/structs/providers/bunny.net/WorkspaceConnectionConfig.v0.ts +42 -0
- package/structs/providers/dynadot.com/DomainFact.v0.ts +146 -0
- package/structs/providers/dynadot.com/WorkspaceConnectionConfig.v0.ts +41 -0
- package/structs/providers/git-scm.com/ProjectPublishingFact.v0.ts +46 -0
- package/structs/providers/github.com/ProjectPublishingFact.v0.ts +52 -0
- package/structs/providers/github.com/WorkspaceConnectionConfig.v0.ts +42 -0
- package/structs/providers/npmjs.com/ProjectPublishingFact.v0.ts +48 -0
- package/structs/providers/vercel.com/ProjectDeploymentFact.v0.ts +38 -0
- package/structs/providers/vercel.com/WorkspaceConnectionConfig.v0.ts +48 -0
- package/tsconfig.json +28 -0
- package/workspace-rt.ts +134 -0
- package/workspace.yaml +5 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env bun test
|
|
2
|
+
|
|
3
|
+
export const testConfig = {
|
|
4
|
+
group: 'vendor',
|
|
5
|
+
runOnAll: false,
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
import * as bunTest from 'bun:test'
|
|
9
|
+
import { run } from '../../../workspace-rt'
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
test: { describe, it, expect },
|
|
13
|
+
domains
|
|
14
|
+
} = await run(async ({ encapsulate, CapsulePropertyTypes, makeImportStack }: any) => {
|
|
15
|
+
const spine = await encapsulate({
|
|
16
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
17
|
+
'#@stream44.studio/encapsulate/structs/Capsule.v0': {},
|
|
18
|
+
'#': {
|
|
19
|
+
test: {
|
|
20
|
+
type: CapsulePropertyTypes.Mapping,
|
|
21
|
+
value: 't44/caps/WorkspaceTest.v0',
|
|
22
|
+
options: {
|
|
23
|
+
'#': {
|
|
24
|
+
bunTest,
|
|
25
|
+
env: {
|
|
26
|
+
DYNADOT_API_KEY: { factReference: 't44/structs/providers/dynadot.com/WorkspaceConnectionConfig.v0:apiKey' }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
domains: {
|
|
32
|
+
type: CapsulePropertyTypes.Mapping,
|
|
33
|
+
value: './api-domains.v0'
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}, {
|
|
38
|
+
importMeta: import.meta,
|
|
39
|
+
importStack: makeImportStack(),
|
|
40
|
+
capsuleName: 't44/caps/providers/dynadot.com/api-domains.v0.test'
|
|
41
|
+
})
|
|
42
|
+
return { spine }
|
|
43
|
+
}, async ({ spine, apis }: any) => {
|
|
44
|
+
return apis[spine.capsuleSourceLineRef]
|
|
45
|
+
}, {
|
|
46
|
+
importMeta: import.meta
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('Dynadot Domain API', function () {
|
|
50
|
+
|
|
51
|
+
let domainName: string
|
|
52
|
+
|
|
53
|
+
it('should return list of domains', async function () {
|
|
54
|
+
|
|
55
|
+
const result = await domains.list()
|
|
56
|
+
|
|
57
|
+
expect(result).toBeObject()
|
|
58
|
+
expect(result.ListDomainInfoResponse).toBeObject()
|
|
59
|
+
expect(result.ListDomainInfoResponse.MainDomains).toBeArray()
|
|
60
|
+
expect(result.ListDomainInfoResponse.MainDomains.length).toBeGreaterThan(0)
|
|
61
|
+
|
|
62
|
+
domainName = result.ListDomainInfoResponse.MainDomains[0].Name
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should return domain info for a specific domain', async function () {
|
|
66
|
+
|
|
67
|
+
const result = await domains.getInfo({ name: domainName })
|
|
68
|
+
|
|
69
|
+
expect(result).toBeObject()
|
|
70
|
+
expect(result.DomainInfoResponse).toBeObject()
|
|
71
|
+
expect(result.DomainInfoResponse.DomainInfo).toBeObject()
|
|
72
|
+
expect(result.DomainInfoResponse.DomainInfo.Name).toBe(domainName)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should return DNS records for a specific domain', async function () {
|
|
76
|
+
|
|
77
|
+
const result = await domains.getDns({ name: domainName })
|
|
78
|
+
|
|
79
|
+
expect(result).toBeObject()
|
|
80
|
+
expect(result.GetDnsResponse).toBeObject()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should return nameservers for a specific domain', async function () {
|
|
84
|
+
|
|
85
|
+
const result = await domains.getNameservers({ name: domainName })
|
|
86
|
+
|
|
87
|
+
expect(result).toBeObject()
|
|
88
|
+
expect(result.GetNsResponse).toBeObject()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should set DNS records, verify, and restore original', async function () {
|
|
92
|
+
|
|
93
|
+
const originalDns = await domains.getDns({ name: domainName })
|
|
94
|
+
expect(originalDns).toBeObject()
|
|
95
|
+
expect(originalDns.GetDnsResponse).toBeObject()
|
|
96
|
+
|
|
97
|
+
const originalRecords = originalDns.GetDnsResponse.GetDns?.NameServerSettings?.SubDomains || []
|
|
98
|
+
|
|
99
|
+
const testCnameRecord = {
|
|
100
|
+
record_type: 'cname',
|
|
101
|
+
subdomain: 't44-test-api-domains',
|
|
102
|
+
value: 'example.com'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const updatedRecords = [...originalRecords.map((r: any) => ({
|
|
106
|
+
record_type: r.RecordType.toLowerCase(),
|
|
107
|
+
subdomain: r.Subhost,
|
|
108
|
+
value: r.Value
|
|
109
|
+
})), testCnameRecord]
|
|
110
|
+
|
|
111
|
+
const setResult = await domains.setDns({
|
|
112
|
+
name: domainName,
|
|
113
|
+
records: updatedRecords
|
|
114
|
+
})
|
|
115
|
+
expect(setResult).toBeObject()
|
|
116
|
+
expect(setResult.SetDnsResponse.Status).toBe('success')
|
|
117
|
+
|
|
118
|
+
const verifyDns = await domains.getDns({ name: domainName })
|
|
119
|
+
expect(verifyDns).toBeObject()
|
|
120
|
+
expect(verifyDns.GetDnsResponse).toBeObject()
|
|
121
|
+
const verifyRecords = verifyDns.GetDnsResponse.GetDns?.NameServerSettings?.SubDomains || []
|
|
122
|
+
expect(verifyRecords.length).toBe(originalRecords.length + 1)
|
|
123
|
+
|
|
124
|
+
const foundNewRecord = verifyRecords.find((r: any) => r.Subhost === 't44-test-api-domains')
|
|
125
|
+
expect(foundNewRecord).toBeDefined()
|
|
126
|
+
expect(foundNewRecord.RecordType).toBe('CNAME')
|
|
127
|
+
expect(foundNewRecord.Value).toBe('example.com')
|
|
128
|
+
|
|
129
|
+
const restoreRecords = originalRecords.map((r: any) => ({
|
|
130
|
+
record_type: r.RecordType.toLowerCase(),
|
|
131
|
+
subdomain: r.Subhost,
|
|
132
|
+
value: r.Value
|
|
133
|
+
}))
|
|
134
|
+
|
|
135
|
+
const restoreResult = await domains.setDns({
|
|
136
|
+
name: domainName,
|
|
137
|
+
records: restoreRecords
|
|
138
|
+
})
|
|
139
|
+
expect(restoreResult).toBeObject()
|
|
140
|
+
|
|
141
|
+
const finalDns = await domains.getDns({ name: domainName })
|
|
142
|
+
expect(finalDns).toBeObject()
|
|
143
|
+
const finalRecords = finalDns.GetDnsResponse.GetDns?.NameServerSettings?.SubDomains || []
|
|
144
|
+
expect(finalRecords.length).toBe(originalRecords.length)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
export async function capsule({
|
|
4
|
+
encapsulate,
|
|
5
|
+
CapsulePropertyTypes,
|
|
6
|
+
makeImportStack
|
|
7
|
+
}: {
|
|
8
|
+
encapsulate: any
|
|
9
|
+
CapsulePropertyTypes: any
|
|
10
|
+
makeImportStack: any
|
|
11
|
+
}) {
|
|
12
|
+
|
|
13
|
+
// https://www.dynadot.com/domain/api-commands
|
|
14
|
+
return encapsulate({
|
|
15
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
16
|
+
'#@stream44.studio/encapsulate/structs/Capsule.v0': {},
|
|
17
|
+
'#t44/structs/providers/dynadot.com/DomainFact.v0': {
|
|
18
|
+
as: '$DomainFact'
|
|
19
|
+
},
|
|
20
|
+
'#': {
|
|
21
|
+
|
|
22
|
+
api: {
|
|
23
|
+
type: CapsulePropertyTypes.Mapping,
|
|
24
|
+
value: './api.v0'
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
list: {
|
|
28
|
+
type: CapsulePropertyTypes.Function,
|
|
29
|
+
value: async function (this: any) {
|
|
30
|
+
const result = await this.api.call({
|
|
31
|
+
command: 'list_domain',
|
|
32
|
+
operation: 'list'
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await this.$DomainFact.set('domains-list', 'list', 'ListDomainInfoResponse', result);
|
|
36
|
+
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
getInfo: {
|
|
42
|
+
type: CapsulePropertyTypes.Function,
|
|
43
|
+
value: async function (this: any, options: { name: string }) {
|
|
44
|
+
const result = await this.api.call({
|
|
45
|
+
command: 'domain_info',
|
|
46
|
+
params: {
|
|
47
|
+
domain: options.name
|
|
48
|
+
},
|
|
49
|
+
operation: 'info'
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await this.$DomainFact.set('domains', options.name, 'DomainInfoResponse', result);
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
getDns: {
|
|
59
|
+
type: CapsulePropertyTypes.Function,
|
|
60
|
+
value: async function (this: any, options: { name: string }) {
|
|
61
|
+
const result = await this.api.call({
|
|
62
|
+
command: 'get_dns',
|
|
63
|
+
params: {
|
|
64
|
+
domain: options.name
|
|
65
|
+
},
|
|
66
|
+
operation: 'getDns'
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await this.$DomainFact.set('dns', options.name, 'GetDnsResponse', result);
|
|
70
|
+
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
getNameservers: {
|
|
76
|
+
type: CapsulePropertyTypes.Function,
|
|
77
|
+
value: async function (this: any, options: { name: string }) {
|
|
78
|
+
const result = await this.api.call({
|
|
79
|
+
command: 'get_ns',
|
|
80
|
+
params: {
|
|
81
|
+
domain: options.name
|
|
82
|
+
},
|
|
83
|
+
operation: 'getNameservers'
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await this.$DomainFact.set('nameservers', options.name, 'GetNsResponse', result);
|
|
87
|
+
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
setDns: {
|
|
93
|
+
type: CapsulePropertyTypes.Function,
|
|
94
|
+
value: async function (this: any, options: { name: string; records: any[]; mainDomain?: { recordType: string; value: string } }) {
|
|
95
|
+
const params: Record<string, any> = {
|
|
96
|
+
domain: options.name
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Set main domain record if provided (for root domain A/AAAA/CNAME)
|
|
100
|
+
// Note: Dynadot API requires index suffix (e.g., main_record_type0, main_record0)
|
|
101
|
+
if (options.mainDomain) {
|
|
102
|
+
params['main_record_type0'] = options.mainDomain.recordType;
|
|
103
|
+
params['main_record0'] = options.mainDomain.value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
options.records.forEach((record, index) => {
|
|
107
|
+
const recordIndex = index + 1;
|
|
108
|
+
|
|
109
|
+
if (record.subdomain !== undefined) {
|
|
110
|
+
params[`subdomain${recordIndex}`] = record.subdomain;
|
|
111
|
+
}
|
|
112
|
+
if (record.record_type) {
|
|
113
|
+
params[`sub_record_type${recordIndex}`] = record.record_type;
|
|
114
|
+
}
|
|
115
|
+
if (record.value !== undefined) {
|
|
116
|
+
params[`sub_record${recordIndex}`] = record.value;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return await this.api.call({
|
|
121
|
+
command: 'set_dns2',
|
|
122
|
+
params,
|
|
123
|
+
operation: 'setDns'
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}, {
|
|
131
|
+
importMeta: import.meta,
|
|
132
|
+
importStack: makeImportStack(),
|
|
133
|
+
capsuleName: capsule['#'],
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
capsule['#'] = 't44/caps/providers/dynadot.com/api-domains.v0'
|
|
137
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
|
|
4
|
+
export async function capsule({
|
|
5
|
+
encapsulate,
|
|
6
|
+
CapsulePropertyTypes,
|
|
7
|
+
makeImportStack
|
|
8
|
+
}: {
|
|
9
|
+
encapsulate: any
|
|
10
|
+
CapsulePropertyTypes: any
|
|
11
|
+
makeImportStack: any
|
|
12
|
+
}) {
|
|
13
|
+
// https://www.dynadot.com/domain/api-commands
|
|
14
|
+
return encapsulate({
|
|
15
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
16
|
+
'#@stream44.studio/encapsulate/structs/Capsule.v0': {},
|
|
17
|
+
'#t44/structs/providers/dynadot.com/WorkspaceConnectionConfig.v0': {
|
|
18
|
+
as: '$ConnectionConfig'
|
|
19
|
+
},
|
|
20
|
+
'#': {
|
|
21
|
+
|
|
22
|
+
call: {
|
|
23
|
+
type: CapsulePropertyTypes.Function,
|
|
24
|
+
value: async function (this: any, options: {
|
|
25
|
+
command: string;
|
|
26
|
+
params?: Record<string, string | number | boolean>;
|
|
27
|
+
operation?: string;
|
|
28
|
+
}) {
|
|
29
|
+
const apiKey = await this.$ConnectionConfig.getConfigValue('apiKey')
|
|
30
|
+
|
|
31
|
+
const baseUrl = 'https://api.dynadot.com/api3.json';
|
|
32
|
+
|
|
33
|
+
// Build query params
|
|
34
|
+
const searchParams = new URLSearchParams();
|
|
35
|
+
searchParams.append('key', apiKey);
|
|
36
|
+
searchParams.append('command', options.command);
|
|
37
|
+
|
|
38
|
+
if (options.params) {
|
|
39
|
+
for (const [key, value] of Object.entries(options.params)) {
|
|
40
|
+
searchParams.append(key, String(value));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const url = `${baseUrl}?${searchParams.toString()}`;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const response = await axios.get(url);
|
|
48
|
+
return response.data;
|
|
49
|
+
} catch (error: any) {
|
|
50
|
+
if (error.response) {
|
|
51
|
+
const errorData = error.response.data;
|
|
52
|
+
const status = error.response.status;
|
|
53
|
+
const statusText = error.response.statusText;
|
|
54
|
+
|
|
55
|
+
const operationName = options.operation || options.command;
|
|
56
|
+
console.error(`Dynadot API Error [${operationName}]:`);
|
|
57
|
+
console.error(` Status: ${status} ${statusText}`);
|
|
58
|
+
console.error(` Response:`, JSON.stringify(errorData, null, 2));
|
|
59
|
+
|
|
60
|
+
let errorMessage = `Dynadot API ${operationName} failed: ${status} ${statusText}`;
|
|
61
|
+
|
|
62
|
+
if (errorData && typeof errorData === 'object') {
|
|
63
|
+
if (errorData.Message) {
|
|
64
|
+
errorMessage += ` - ${errorData.Message}`;
|
|
65
|
+
} else if (errorData.message) {
|
|
66
|
+
errorMessage += ` - ${errorData.message}`;
|
|
67
|
+
} else {
|
|
68
|
+
errorMessage += ` - ${JSON.stringify(errorData)}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
throw new Error(errorMessage);
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}, {
|
|
82
|
+
importMeta: import.meta,
|
|
83
|
+
importStack: makeImportStack(),
|
|
84
|
+
capsuleName: capsule['#'],
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
capsule['#'] = 't44/caps/providers/dynadot.com/api.v0'
|
|
88
|
+
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { $ } from 'bun'
|
|
4
|
+
import { mkdir, access, writeFile } from 'fs/promises'
|
|
5
|
+
import { constants } from 'fs'
|
|
6
|
+
import chalk from 'chalk'
|
|
7
|
+
|
|
8
|
+
export async function capsule({
|
|
9
|
+
encapsulate,
|
|
10
|
+
CapsulePropertyTypes,
|
|
11
|
+
makeImportStack
|
|
12
|
+
}: {
|
|
13
|
+
encapsulate: any
|
|
14
|
+
CapsulePropertyTypes: any
|
|
15
|
+
makeImportStack: any
|
|
16
|
+
}) {
|
|
17
|
+
// High level API that deals with everything concerning a git repository.
|
|
18
|
+
return encapsulate({
|
|
19
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
20
|
+
'#@stream44.studio/encapsulate/structs/Capsule.v0': {},
|
|
21
|
+
'#t44/structs/providers/git-scm.com/ProjectPublishingFact.v0': {
|
|
22
|
+
as: '$GitFact'
|
|
23
|
+
},
|
|
24
|
+
'#t44/structs/ProjectPublishingFact.v0': {
|
|
25
|
+
as: '$StatusFact'
|
|
26
|
+
},
|
|
27
|
+
'#': {
|
|
28
|
+
WorkspacePrompt: {
|
|
29
|
+
type: CapsulePropertyTypes.Mapping,
|
|
30
|
+
value: 't44/caps/WorkspacePrompt.v0'
|
|
31
|
+
},
|
|
32
|
+
push: {
|
|
33
|
+
type: CapsulePropertyTypes.Function,
|
|
34
|
+
value: async function (this: any, { projectionDir, config, dangerouslyResetMain }: { projectionDir: string, config: any, dangerouslyResetMain?: boolean }) {
|
|
35
|
+
|
|
36
|
+
const originUri = config.provider.config.RepositorySettings.origin
|
|
37
|
+
|
|
38
|
+
console.log(`Push source '${config.sourceDir}' to git repo '${originUri}' ...`)
|
|
39
|
+
if (dangerouslyResetMain) {
|
|
40
|
+
console.log(`Reset mode enabled - will reset repository to initial commit`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const projectSourceDir = join(config.sourceDir)
|
|
44
|
+
const projectProjectionDir = join(projectionDir, 'repos', originUri.replace(/[@:\/]/g, '~'))
|
|
45
|
+
|
|
46
|
+
// Check if repository directory exists
|
|
47
|
+
let repoExists = false
|
|
48
|
+
try {
|
|
49
|
+
await access(projectProjectionDir, constants.F_OK)
|
|
50
|
+
repoExists = true
|
|
51
|
+
} catch {
|
|
52
|
+
repoExists = false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let isNewEmptyRepo = false
|
|
56
|
+
if (!repoExists) {
|
|
57
|
+
// Clone the repository if it doesn't exist
|
|
58
|
+
console.log(`Cloning repository from '${originUri}' ...`)
|
|
59
|
+
const reposDir = join(projectionDir, 'repos')
|
|
60
|
+
await mkdir(reposDir, { recursive: true })
|
|
61
|
+
await $`git clone "${originUri}" "${projectProjectionDir}"`.cwd(reposDir)
|
|
62
|
+
|
|
63
|
+
// Check if the cloned repo is empty (no commits)
|
|
64
|
+
const headCheck = await $`git rev-parse HEAD`.cwd(projectProjectionDir).quiet().nothrow()
|
|
65
|
+
if (headCheck.exitCode !== 0) {
|
|
66
|
+
isNewEmptyRepo = true
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Sync files using rsync with gitignore support and delete removed files
|
|
71
|
+
// --exclude-from reads patterns from .gitignore
|
|
72
|
+
// --exclude '.git' ensures we don't copy git metadata
|
|
73
|
+
// --delete removes files in dest that are no longer in source
|
|
74
|
+
const gitignorePath = join(projectSourceDir, '.gitignore')
|
|
75
|
+
await $`rsync -a --delete --exclude '.git' --exclude-from="${gitignorePath}" "${projectSourceDir}/" "${projectProjectionDir}/"`
|
|
76
|
+
|
|
77
|
+
// Generate files from config properties starting with '/'
|
|
78
|
+
// This happens AFTER rsync so generated files are not overwritten
|
|
79
|
+
if (config.provider.config) {
|
|
80
|
+
for (const [key, value] of Object.entries(config.provider.config)) {
|
|
81
|
+
if (key.startsWith('/')) {
|
|
82
|
+
const targetPath = join(projectProjectionDir, key)
|
|
83
|
+
const targetDir = join(targetPath, '..')
|
|
84
|
+
|
|
85
|
+
// Check if file already exists
|
|
86
|
+
let fileExists = false
|
|
87
|
+
try {
|
|
88
|
+
await access(targetPath, constants.F_OK)
|
|
89
|
+
fileExists = true
|
|
90
|
+
} catch {
|
|
91
|
+
fileExists = false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (fileExists) {
|
|
95
|
+
console.log(`Overwriting file '${key}' in repository ...`)
|
|
96
|
+
} else {
|
|
97
|
+
console.log(`Creating file '${key}' in repository ...`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Ensure directory exists
|
|
101
|
+
await mkdir(targetDir, { recursive: true })
|
|
102
|
+
|
|
103
|
+
// Write file content
|
|
104
|
+
const content = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
|
105
|
+
await writeFile(targetPath, content, 'utf-8')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Git add, commit, and push changes
|
|
111
|
+
console.log(`Committing changes ...`)
|
|
112
|
+
await $`git add .`.cwd(projectProjectionDir)
|
|
113
|
+
|
|
114
|
+
// Check if there are changes to commit
|
|
115
|
+
const statusResult = await $`git status --porcelain`.cwd(projectProjectionDir).text()
|
|
116
|
+
const hasNewChanges = statusResult.trim().length > 0
|
|
117
|
+
|
|
118
|
+
// Handle reset (works on existing commits, regardless of new changes)
|
|
119
|
+
let shouldReset = false
|
|
120
|
+
if (dangerouslyResetMain) {
|
|
121
|
+
shouldReset = await this.WorkspacePrompt.confirm({
|
|
122
|
+
title: 'ā ļø WARNING: DESTRUCTIVE OPERATION ā ļø',
|
|
123
|
+
description: [
|
|
124
|
+
'āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā',
|
|
125
|
+
'Resetting will:',
|
|
126
|
+
' ⢠Destroy all commit history in the local repository',
|
|
127
|
+
' ⢠Destroy all commit history on GitHub when force pushed',
|
|
128
|
+
' ⢠Cannot be undone once pushed to remote',
|
|
129
|
+
'',
|
|
130
|
+
'This should ONLY be done at the very beginning of a project.',
|
|
131
|
+
'āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā'
|
|
132
|
+
],
|
|
133
|
+
message: 'Are you absolutely sure you want to reset all commits and destroy the history?',
|
|
134
|
+
defaultValue: false,
|
|
135
|
+
onSuccess: async (confirmed: boolean) => {
|
|
136
|
+
if (confirmed) {
|
|
137
|
+
const chalk = (await import('chalk')).default
|
|
138
|
+
console.log(chalk.cyan(`\nResetting all commits to initial commit ...`))
|
|
139
|
+
} else {
|
|
140
|
+
console.log('\nReset operation cancelled. Pushing without resetting...\n')
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
if (shouldReset) {
|
|
146
|
+
// Get the initial commit (root commit)
|
|
147
|
+
const rootCommit = await $`git rev-list --max-parents=0 HEAD`.cwd(projectProjectionDir).text()
|
|
148
|
+
const rootCommitHash = rootCommit.trim()
|
|
149
|
+
|
|
150
|
+
// Soft reset to the root commit, keeping all changes staged
|
|
151
|
+
await $`git reset --soft ${rootCommitHash}`.cwd(projectProjectionDir)
|
|
152
|
+
|
|
153
|
+
// Commit all changes as a single commit (this replaces the root commit)
|
|
154
|
+
await $`git commit --amend -m "Published using @Stream44 Studio"`.cwd(projectProjectionDir)
|
|
155
|
+
|
|
156
|
+
console.log(`Repository reset to initial commit`)
|
|
157
|
+
}
|
|
158
|
+
} else if (hasNewChanges) {
|
|
159
|
+
// Only commit new changes if not resetting
|
|
160
|
+
await $`git commit -m "Published using @Stream44 Studio"`.cwd(projectProjectionDir)
|
|
161
|
+
console.log(`New changes committed`)
|
|
162
|
+
} else {
|
|
163
|
+
console.log(`No new changes to commit`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Check if local is ahead of remote using ls-remote (not cached refs)
|
|
167
|
+
let localAheadOfRemote = false
|
|
168
|
+
if (!shouldReset && !hasNewChanges && !isNewEmptyRepo) {
|
|
169
|
+
const lsRemoteResult = await $`git ls-remote origin`.cwd(projectProjectionDir).quiet().nothrow()
|
|
170
|
+
const lsRemoteOutput = lsRemoteResult.text().trim()
|
|
171
|
+
|
|
172
|
+
if (!lsRemoteOutput) {
|
|
173
|
+
// Remote is completely empty ā must push
|
|
174
|
+
localAheadOfRemote = true
|
|
175
|
+
} else {
|
|
176
|
+
// Parse remote HEAD ref
|
|
177
|
+
const localHead = (await $`git rev-parse HEAD`.cwd(projectProjectionDir).quiet()).text().trim()
|
|
178
|
+
const remoteHeadLine = lsRemoteOutput.split('\n').find((l: string) => l.includes('refs/heads/main'))
|
|
179
|
+
const remoteHead = remoteHeadLine ? remoteHeadLine.split('\t')[0] : null
|
|
180
|
+
|
|
181
|
+
if (!remoteHead || remoteHead !== localHead) {
|
|
182
|
+
localAheadOfRemote = true
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Push to remote
|
|
188
|
+
if (shouldReset) {
|
|
189
|
+
console.log(`Force pushing to remote ...`)
|
|
190
|
+
await $`git push --force --tags`.cwd(projectProjectionDir)
|
|
191
|
+
console.log(`Force pushed to remote`)
|
|
192
|
+
} else if (isNewEmptyRepo || hasNewChanges || localAheadOfRemote) {
|
|
193
|
+
console.log(`Pushing to remote ...`)
|
|
194
|
+
await $`git push -u origin main --tags`.cwd(projectProjectionDir)
|
|
195
|
+
console.log(`Pushed to remote`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Write fact files
|
|
199
|
+
const lastCommit = await $`git rev-parse HEAD`.cwd(projectProjectionDir).text()
|
|
200
|
+
const lastCommitMessage = await $`git log -1 --pretty=%B`.cwd(projectProjectionDir).text()
|
|
201
|
+
const branch = await $`git rev-parse --abbrev-ref HEAD`.cwd(projectProjectionDir).text()
|
|
202
|
+
|
|
203
|
+
const repoFactName = originUri.replace(/[@:\/]/g, '~')
|
|
204
|
+
|
|
205
|
+
await this.$GitFact.set('repositories', repoFactName, 'GitRepository', {
|
|
206
|
+
origin: originUri,
|
|
207
|
+
branch: branch.trim(),
|
|
208
|
+
lastCommit: lastCommit.trim(),
|
|
209
|
+
lastCommitMessage: lastCommitMessage.trim(),
|
|
210
|
+
pushedAt: new Date().toISOString()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
await this.$StatusFact.set('ProjectPublishingStatus', repoFactName, 'ProjectPublishingStatus', {
|
|
214
|
+
projectName: originUri,
|
|
215
|
+
provider: 'git-scm.com',
|
|
216
|
+
status: hasNewChanges || shouldReset || localAheadOfRemote ? 'PUBLISHED' : 'READY',
|
|
217
|
+
publicUrl: originUri,
|
|
218
|
+
updatedAt: new Date().toISOString()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}, {
|
|
226
|
+
importMeta: import.meta,
|
|
227
|
+
importStack: makeImportStack(),
|
|
228
|
+
capsuleName: capsule['#'],
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
capsule['#'] = 't44/caps/providers/git-scm.com/ProjectPublishing.v0'
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
|
|
4
|
+
export async function capsule({
|
|
5
|
+
encapsulate,
|
|
6
|
+
CapsulePropertyTypes,
|
|
7
|
+
makeImportStack
|
|
8
|
+
}: {
|
|
9
|
+
encapsulate: any
|
|
10
|
+
CapsulePropertyTypes: any
|
|
11
|
+
makeImportStack: any
|
|
12
|
+
}) {
|
|
13
|
+
// Ensures the GitHub repository is provisioned before git-scm pushes to it.
|
|
14
|
+
return encapsulate({
|
|
15
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
16
|
+
'#@stream44.studio/encapsulate/structs/Capsule.v0': {},
|
|
17
|
+
'#t44/structs/providers/github.com/ProjectPublishingFact.v0': {
|
|
18
|
+
as: '$GitHubFact'
|
|
19
|
+
},
|
|
20
|
+
'#t44/structs/ProjectPublishingFact.v0': {
|
|
21
|
+
as: '$StatusFact'
|
|
22
|
+
},
|
|
23
|
+
'#': {
|
|
24
|
+
GitHubApi: {
|
|
25
|
+
type: CapsulePropertyTypes.Mapping,
|
|
26
|
+
value: 't44/caps/providers/github.com/api.v0'
|
|
27
|
+
},
|
|
28
|
+
push: {
|
|
29
|
+
type: CapsulePropertyTypes.Function,
|
|
30
|
+
value: async function (this: any, { config }: { config: any }) {
|
|
31
|
+
|
|
32
|
+
const repoSettings = config.provider.config.RepositorySettings
|
|
33
|
+
const owner = repoSettings.owner
|
|
34
|
+
const repo = repoSettings.repo
|
|
35
|
+
const isPrivate = repoSettings.public === true ? false : true
|
|
36
|
+
const description = repoSettings.description || ''
|
|
37
|
+
|
|
38
|
+
console.log(chalk.cyan(`\nš Ensuring GitHub repository '${owner}/${repo}' ...`))
|
|
39
|
+
|
|
40
|
+
const result = await this.GitHubApi.ensureRepo({
|
|
41
|
+
owner,
|
|
42
|
+
repo,
|
|
43
|
+
isPrivate,
|
|
44
|
+
description
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
if (result.created) {
|
|
48
|
+
console.log(chalk.green(` ā Repository '${owner}/${repo}' created on GitHub\n`))
|
|
49
|
+
} else {
|
|
50
|
+
console.log(chalk.green(` ā Repository '${owner}/${repo}' already exists\n`))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Write provider-specific fact file
|
|
54
|
+
await this.$GitHubFact.set('repositories', `${owner}~${repo}`, 'Repository', result.repo)
|
|
55
|
+
|
|
56
|
+
// Write publishing status fact
|
|
57
|
+
const statusResult = {
|
|
58
|
+
projectName: `${owner}/${repo}`,
|
|
59
|
+
provider: 'github.com',
|
|
60
|
+
status: 'READY',
|
|
61
|
+
publicUrl: result.repo.html_url,
|
|
62
|
+
updatedAt: new Date().toISOString()
|
|
63
|
+
}
|
|
64
|
+
await this.$StatusFact.set('ProjectPublishingStatus', `${owner}~${repo}`, 'ProjectPublishingStatus', statusResult)
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}, {
|
|
70
|
+
importMeta: import.meta,
|
|
71
|
+
importStack: makeImportStack(),
|
|
72
|
+
capsuleName: capsule['#'],
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
capsule['#'] = 't44/caps/providers/github.com/ProjectPublishing.v0'
|