@ankhorage/infra 0.2.1 → 0.3.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.
- package/CHANGELOG.md +12 -0
- package/README.md +43 -2
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +27 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +39 -0
- package/dist/cli.js.map +1 -0
- package/dist/commandContext.d.ts +12 -0
- package/dist/commandContext.d.ts.map +1 -0
- package/dist/commandContext.js +15 -0
- package/dist/commandContext.js.map +1 -0
- package/dist/commands.d.ts +95 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +256 -0
- package/dist/commands.js.map +1 -0
- package/dist/infraValidation.d.ts +3 -0
- package/dist/infraValidation.d.ts.map +1 -0
- package/dist/infraValidation.js +58 -0
- package/dist/infraValidation.js.map +1 -0
- package/dist/metadata/index.d.ts +2 -0
- package/dist/metadata/index.d.ts.map +1 -0
- package/dist/metadata/index.js +2 -0
- package/dist/metadata/index.js.map +1 -0
- package/dist/packageMetadata.d.ts +10 -0
- package/dist/packageMetadata.d.ts.map +1 -0
- package/dist/packageMetadata.js +17 -0
- package/dist/packageMetadata.js.map +1 -0
- package/dist/project.d.ts +14 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +202 -0
- package/dist/project.js.map +1 -0
- package/dist/projectInfrastructure.d.ts +22 -0
- package/dist/projectInfrastructure.d.ts.map +1 -0
- package/dist/projectInfrastructure.js +120 -0
- package/dist/projectInfrastructure.js.map +1 -0
- package/dist/readme-usage.d.ts +2 -0
- package/dist/readme-usage.d.ts.map +1 -0
- package/dist/readme-usage.js +30 -0
- package/dist/readme-usage.js.map +1 -0
- package/dist/runtime.d.ts +26 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +87 -0
- package/dist/runtime.js.map +1 -0
- package/dist/testSupport.d.ts +24 -0
- package/dist/testSupport.d.ts.map +1 -0
- package/dist/testSupport.js +73 -0
- package/dist/testSupport.js.map +1 -0
- package/package.json +23 -3
- package/src/cli/index.test.ts +32 -0
- package/src/cli/index.ts +53 -0
- package/src/cli.test.ts +137 -0
- package/src/cli.ts +67 -0
- package/src/commandContext.ts +27 -0
- package/src/commands.test.ts +219 -0
- package/src/commands.ts +404 -0
- package/src/infraValidation.ts +90 -0
- package/src/metadata/index.ts +7 -0
- package/src/package.test.ts +27 -0
- package/src/packageMetadata.ts +24 -0
- package/src/project.test.ts +218 -0
- package/src/project.ts +270 -0
- package/src/projectInfrastructure.test.ts +207 -0
- package/src/projectInfrastructure.ts +177 -0
- package/src/readme-usage.ts +30 -0
- package/src/runtime.test.ts +124 -0
- package/src/runtime.ts +127 -0
- package/src/testSupport.ts +112 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AnkhPackageMetadata } from '@ankhorage/contracts/cli';
|
|
2
|
+
import { describe, expect, test } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import packageJson from '../package.json';
|
|
5
|
+
|
|
6
|
+
describe('package metadata', () => {
|
|
7
|
+
test('publishes the expected Ankh metadata and bin entry', () => {
|
|
8
|
+
const expectedAnkhMetadata = {
|
|
9
|
+
category: 'infra',
|
|
10
|
+
provider: './dist/cli/index.js',
|
|
11
|
+
capabilities: ['infra.validate', 'infra.generate', 'infra.status', 'infra.up', 'infra.down'],
|
|
12
|
+
} as const satisfies AnkhPackageMetadata;
|
|
13
|
+
|
|
14
|
+
expect(packageJson.name).toBe('@ankhorage/infra');
|
|
15
|
+
expect(packageJson.type).toBe('module');
|
|
16
|
+
expect(packageJson.bin).toEqual({
|
|
17
|
+
'ankhorage-infra': './dist/cli.js',
|
|
18
|
+
});
|
|
19
|
+
expect(packageJson.ankh).toEqual(expectedAnkhMetadata);
|
|
20
|
+
expect(JSON.parse(JSON.stringify(expectedAnkhMetadata))).toEqual(expectedAnkhMetadata);
|
|
21
|
+
|
|
22
|
+
const capabilityText = JSON.stringify(packageJson.ankh.capabilities);
|
|
23
|
+
expect(capabilityText).not.toContain('portForward');
|
|
24
|
+
expect(capabilityText).not.toContain('port-forward');
|
|
25
|
+
expect(capabilityText).not.toContain('port.forward');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnkhCapabilityId,
|
|
3
|
+
AnkhCommandCategory,
|
|
4
|
+
AnkhPackageMetadata,
|
|
5
|
+
} from '@ankhorage/contracts/cli';
|
|
6
|
+
|
|
7
|
+
import packageJson from '../package.json';
|
|
8
|
+
|
|
9
|
+
export const INFRA_PACKAGE_NAME = packageJson.name;
|
|
10
|
+
export const INFRA_PACKAGE_VERSION = packageJson.version;
|
|
11
|
+
export const INFRA_COMMAND_CATEGORY = 'infra' as const satisfies AnkhCommandCategory;
|
|
12
|
+
export const INFRA_CAPABILITIES = [
|
|
13
|
+
'infra.validate',
|
|
14
|
+
'infra.generate',
|
|
15
|
+
'infra.status',
|
|
16
|
+
'infra.up',
|
|
17
|
+
'infra.down',
|
|
18
|
+
] as const satisfies readonly AnkhCapabilityId[];
|
|
19
|
+
|
|
20
|
+
export const INFRA_PACKAGE_METADATA = {
|
|
21
|
+
category: INFRA_COMMAND_CATEGORY,
|
|
22
|
+
provider: './dist/ankh.provider.js',
|
|
23
|
+
capabilities: INFRA_CAPABILITIES,
|
|
24
|
+
} as const satisfies AnkhPackageMetadata;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
import { resolveInfraProject } from './project.js';
|
|
7
|
+
import { createAppManifest, createWorkspaceFixture } from './testSupport.js';
|
|
8
|
+
|
|
9
|
+
const tempRoots = new Set<string>();
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await Promise.all(
|
|
13
|
+
[...tempRoots].map((rootPath) => fs.rm(rootPath, { force: true, recursive: true })),
|
|
14
|
+
);
|
|
15
|
+
tempRoots.clear();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('resolveInfraProject', () => {
|
|
19
|
+
test('resolves an explicit project id from the workspace root', async () => {
|
|
20
|
+
const fixture = await createWorkspaceFixture();
|
|
21
|
+
tempRoots.add(fixture.rootPath);
|
|
22
|
+
|
|
23
|
+
const project = await resolveInfraProject({
|
|
24
|
+
cwd: fixture.rootPath,
|
|
25
|
+
projectId: fixture.projectId,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(project.projectId).toBe('shop');
|
|
29
|
+
expect(project.projectPath).toBe(fixture.projectPath);
|
|
30
|
+
expect(project.workspaceRoot).toBe(fixture.rootPath);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('infers the project id from cwd inside apps/<project>', async () => {
|
|
34
|
+
const fixture = await createWorkspaceFixture();
|
|
35
|
+
tempRoots.add(fixture.rootPath);
|
|
36
|
+
|
|
37
|
+
const nestedPath = path.join(fixture.projectPath, 'src', 'modules');
|
|
38
|
+
await fs.mkdir(nestedPath, { recursive: true });
|
|
39
|
+
|
|
40
|
+
const project = await resolveInfraProject({
|
|
41
|
+
cwd: nestedPath,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(project.projectId).toBe('shop');
|
|
45
|
+
expect(project.projectPath).toBe(fixture.projectPath);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('loads the fallback manifest when ankh.config.json is missing', async () => {
|
|
49
|
+
const fixture = await createWorkspaceFixture({
|
|
50
|
+
manifestFile: 'missing',
|
|
51
|
+
});
|
|
52
|
+
tempRoots.add(fixture.rootPath);
|
|
53
|
+
|
|
54
|
+
const project = await resolveInfraProject({
|
|
55
|
+
cwd: fixture.rootPath,
|
|
56
|
+
projectId: fixture.projectId,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(project.manifest.metadata.name).toBe('shop');
|
|
60
|
+
expect(project.manifest.metadata.slug).toBe('shop');
|
|
61
|
+
expect(project.manifest.infra.plugins).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('rejects malformed manifest JSON', async () => {
|
|
65
|
+
const fixture = await createWorkspaceFixture({
|
|
66
|
+
manifestFile: 'invalid-json',
|
|
67
|
+
});
|
|
68
|
+
tempRoots.add(fixture.rootPath);
|
|
69
|
+
|
|
70
|
+
await expectRejectMessage(
|
|
71
|
+
resolveInfraProject({
|
|
72
|
+
cwd: fixture.rootPath,
|
|
73
|
+
projectId: fixture.projectId,
|
|
74
|
+
}),
|
|
75
|
+
'Project manifest is not valid JSON',
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('rejects traversal project ids', async () => {
|
|
80
|
+
const fixture = await createWorkspaceFixture();
|
|
81
|
+
tempRoots.add(fixture.rootPath);
|
|
82
|
+
|
|
83
|
+
await expectRejectMessage(
|
|
84
|
+
resolveInfraProject({
|
|
85
|
+
cwd: fixture.rootPath,
|
|
86
|
+
projectId: '../shop',
|
|
87
|
+
}),
|
|
88
|
+
'Invalid project id:',
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('rejects explicit project ids with nested traversal segments', async () => {
|
|
93
|
+
const fixture = await createWorkspaceFixture();
|
|
94
|
+
tempRoots.add(fixture.rootPath);
|
|
95
|
+
|
|
96
|
+
await expectRejectMessage(
|
|
97
|
+
resolveInfraProject({
|
|
98
|
+
cwd: fixture.rootPath,
|
|
99
|
+
projectId: 'shop/../other',
|
|
100
|
+
}),
|
|
101
|
+
'Invalid project id:',
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('rejects explicit absolute project ids', async () => {
|
|
106
|
+
const fixture = await createWorkspaceFixture();
|
|
107
|
+
tempRoots.add(fixture.rootPath);
|
|
108
|
+
|
|
109
|
+
await expectRejectMessage(
|
|
110
|
+
resolveInfraProject({
|
|
111
|
+
cwd: fixture.rootPath,
|
|
112
|
+
projectId: '/tmp/shop',
|
|
113
|
+
}),
|
|
114
|
+
'Invalid project id:',
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('rejects explicit dot project ids', async () => {
|
|
119
|
+
const fixture = await createWorkspaceFixture();
|
|
120
|
+
tempRoots.add(fixture.rootPath);
|
|
121
|
+
|
|
122
|
+
await expectRejectMessage(
|
|
123
|
+
resolveInfraProject({
|
|
124
|
+
cwd: fixture.rootPath,
|
|
125
|
+
projectId: '.',
|
|
126
|
+
}),
|
|
127
|
+
'Invalid project id:',
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('rejects explicit dot-dot project ids', async () => {
|
|
132
|
+
const fixture = await createWorkspaceFixture();
|
|
133
|
+
tempRoots.add(fixture.rootPath);
|
|
134
|
+
|
|
135
|
+
await expectRejectMessage(
|
|
136
|
+
resolveInfraProject({
|
|
137
|
+
cwd: fixture.rootPath,
|
|
138
|
+
projectId: '..',
|
|
139
|
+
}),
|
|
140
|
+
'Invalid project id:',
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('rejects explicit empty project ids', async () => {
|
|
145
|
+
const fixture = await createWorkspaceFixture();
|
|
146
|
+
tempRoots.add(fixture.rootPath);
|
|
147
|
+
|
|
148
|
+
await expectRejectMessage(
|
|
149
|
+
resolveInfraProject({
|
|
150
|
+
cwd: fixture.rootPath,
|
|
151
|
+
projectId: '',
|
|
152
|
+
}),
|
|
153
|
+
'Invalid project id:',
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('rejects explicit blank project ids', async () => {
|
|
158
|
+
const fixture = await createWorkspaceFixture();
|
|
159
|
+
tempRoots.add(fixture.rootPath);
|
|
160
|
+
|
|
161
|
+
await expectRejectMessage(
|
|
162
|
+
resolveInfraProject({
|
|
163
|
+
cwd: fixture.rootPath,
|
|
164
|
+
projectId: ' ',
|
|
165
|
+
}),
|
|
166
|
+
'Invalid project id:',
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('fails when no workspace root can be found', async () => {
|
|
171
|
+
const rootPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-no-root-'));
|
|
172
|
+
tempRoots.add(rootPath);
|
|
173
|
+
|
|
174
|
+
await expectRejectMessage(
|
|
175
|
+
resolveInfraProject({
|
|
176
|
+
cwd: rootPath,
|
|
177
|
+
projectId: 'shop',
|
|
178
|
+
}),
|
|
179
|
+
'Could not find an Ankh workspace root',
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('reads a valid project manifest', async () => {
|
|
184
|
+
const manifest = createAppManifest('cards', {
|
|
185
|
+
deployment: {
|
|
186
|
+
monitoring: false,
|
|
187
|
+
target: 'minikube',
|
|
188
|
+
},
|
|
189
|
+
plugins: [],
|
|
190
|
+
});
|
|
191
|
+
const fixture = await createWorkspaceFixture({
|
|
192
|
+
manifest,
|
|
193
|
+
projectId: 'cards',
|
|
194
|
+
});
|
|
195
|
+
tempRoots.add(fixture.rootPath);
|
|
196
|
+
|
|
197
|
+
const project = await resolveInfraProject({
|
|
198
|
+
cwd: fixture.rootPath,
|
|
199
|
+
projectId: 'cards',
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(project.manifest.metadata.name).toBe('cards');
|
|
203
|
+
expect(project.manifest.infra.deployment?.target).toBe('minikube');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
async function expectRejectMessage(promise: Promise<unknown>, text: string): Promise<void> {
|
|
208
|
+
let didReject = false;
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await promise;
|
|
212
|
+
} catch (error) {
|
|
213
|
+
didReject = true;
|
|
214
|
+
expect(error instanceof Error ? error.message : String(error)).toContain(text);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
expect(didReject).toBe(true);
|
|
218
|
+
}
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { AppManifest, AuthFlowConfig, NavigatorSpec } from '@ankhorage/contracts';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
export interface ResolvedInfraProject {
|
|
6
|
+
readonly appsRoot: string;
|
|
7
|
+
readonly manifest: AppManifest;
|
|
8
|
+
readonly manifestPath: string;
|
|
9
|
+
readonly projectId: string;
|
|
10
|
+
readonly projectPath: string;
|
|
11
|
+
readonly workspaceRoot: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function resolveInfraProject(options: {
|
|
15
|
+
readonly cwd: string;
|
|
16
|
+
readonly projectId?: string;
|
|
17
|
+
}): Promise<ResolvedInfraProject> {
|
|
18
|
+
const workspaceRoot = await findWorkspaceRoot(options.cwd);
|
|
19
|
+
if (workspaceRoot === null) {
|
|
20
|
+
throw new Error(`Could not find an Ankh workspace root from cwd: ${options.cwd}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const appsRoot = path.join(workspaceRoot, 'apps');
|
|
24
|
+
const projectId =
|
|
25
|
+
options.projectId === undefined
|
|
26
|
+
? inferProjectId(options.cwd, appsRoot)
|
|
27
|
+
: validateExplicitProjectId(options.projectId);
|
|
28
|
+
if (projectId === null) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
'Could not infer a project from cwd. Pass a project id or run the command inside apps/<project>.',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const projectPath = resolveProjectPath(appsRoot, projectId);
|
|
35
|
+
if (!(await pathExists(projectPath))) {
|
|
36
|
+
throw new Error(`Project '${projectId}' not found.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const manifestPath = path.join(projectPath, 'ankh.config.json');
|
|
40
|
+
const manifest = await readProjectManifest({
|
|
41
|
+
manifestPath,
|
|
42
|
+
projectId,
|
|
43
|
+
projectPath,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
appsRoot,
|
|
48
|
+
manifest,
|
|
49
|
+
manifestPath,
|
|
50
|
+
projectId,
|
|
51
|
+
projectPath,
|
|
52
|
+
workspaceRoot,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveProjectPath(appsRoot: string, projectId: string): string {
|
|
57
|
+
const projectPath = path.resolve(appsRoot, projectId);
|
|
58
|
+
const appsRootPath = `${path.resolve(appsRoot)}${path.sep}`;
|
|
59
|
+
|
|
60
|
+
if (!projectPath.startsWith(appsRootPath)) {
|
|
61
|
+
throw new Error('Security check failed while resolving the project path.');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return projectPath;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createFallbackManifest(projectId: string): AppManifest {
|
|
68
|
+
return {
|
|
69
|
+
metadata: {
|
|
70
|
+
name: projectId,
|
|
71
|
+
slug: projectId,
|
|
72
|
+
version: '0.0.0',
|
|
73
|
+
themeId: 'default',
|
|
74
|
+
},
|
|
75
|
+
themes: [],
|
|
76
|
+
activeThemeId: 'default',
|
|
77
|
+
infra: {
|
|
78
|
+
plugins: [],
|
|
79
|
+
},
|
|
80
|
+
navigator: {
|
|
81
|
+
type: 'stack',
|
|
82
|
+
routes: [{ name: 'index', screenId: 'index' }],
|
|
83
|
+
},
|
|
84
|
+
screens: {},
|
|
85
|
+
settings: {
|
|
86
|
+
localization: {
|
|
87
|
+
defaultLocale: 'en',
|
|
88
|
+
locales: ['en'],
|
|
89
|
+
},
|
|
90
|
+
authFlow: {
|
|
91
|
+
signInRoute: '/sign-in',
|
|
92
|
+
signOutRoute: '/sign-out',
|
|
93
|
+
postSignInRoute: '/',
|
|
94
|
+
unauthorizedRoute: '/sign-in',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function readProjectManifest(args: {
|
|
101
|
+
readonly manifestPath: string;
|
|
102
|
+
readonly projectId: string;
|
|
103
|
+
readonly projectPath: string;
|
|
104
|
+
}): Promise<AppManifest> {
|
|
105
|
+
if (!(await pathExists(args.manifestPath))) {
|
|
106
|
+
return createFallbackManifest(args.projectId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rawManifest = await fs.readFile(args.manifestPath, 'utf8');
|
|
110
|
+
|
|
111
|
+
let parsed: unknown;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(rawManifest);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Project manifest is not valid JSON: ${args.manifestPath}: ${getErrorMessage(error)}`,
|
|
117
|
+
{ cause: error },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!isAppManifest(parsed)) {
|
|
122
|
+
throw new Error(`Project manifest has an invalid shape: ${args.manifestPath}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function findWorkspaceRoot(startPath: string): Promise<string | null> {
|
|
129
|
+
let currentPath = path.resolve(startPath);
|
|
130
|
+
|
|
131
|
+
for (;;) {
|
|
132
|
+
const appsRoot = path.join(currentPath, 'apps');
|
|
133
|
+
if (await isDirectory(appsRoot)) {
|
|
134
|
+
return currentPath;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const parentPath = path.dirname(currentPath);
|
|
138
|
+
if (parentPath === currentPath) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
currentPath = parentPath;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function inferProjectId(cwd: string, appsRoot: string): string | null {
|
|
147
|
+
const relativePath = path.relative(path.resolve(appsRoot), path.resolve(cwd));
|
|
148
|
+
if (relativePath === '' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const [projectId] = relativePath.split(path.sep);
|
|
153
|
+
return projectId === undefined || projectId.length === 0 ? null : path.basename(projectId);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function validateExplicitProjectId(projectId: string): string {
|
|
157
|
+
if (projectId.trim().length === 0) {
|
|
158
|
+
throw new Error('Invalid project id: expected a single safe project segment.');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
projectId === '.' ||
|
|
163
|
+
projectId === '..' ||
|
|
164
|
+
path.isAbsolute(projectId) ||
|
|
165
|
+
projectId.includes('/') ||
|
|
166
|
+
projectId.includes('\\')
|
|
167
|
+
) {
|
|
168
|
+
throw new Error(`Invalid project id: ${projectId}. Expected a single safe project segment.`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return projectId;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isAppManifest(value: unknown): value is AppManifest {
|
|
175
|
+
if (!isRecord(value)) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
isAppManifestMetadata(value.metadata) &&
|
|
181
|
+
Array.isArray(value.themes) &&
|
|
182
|
+
typeof value.activeThemeId === 'string' &&
|
|
183
|
+
isInfraManifestRecord(value.infra) &&
|
|
184
|
+
isNavigatorSpec(value.navigator) &&
|
|
185
|
+
isRecord(value.screens) &&
|
|
186
|
+
isSettingsRecord(value.settings)
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isAppManifestMetadata(value: unknown): value is AppManifest['metadata'] {
|
|
191
|
+
return (
|
|
192
|
+
isRecord(value) &&
|
|
193
|
+
typeof value.name === 'string' &&
|
|
194
|
+
typeof value.slug === 'string' &&
|
|
195
|
+
typeof value.version === 'string' &&
|
|
196
|
+
typeof value.themeId === 'string' &&
|
|
197
|
+
(value.created === undefined || typeof value.created === 'string') &&
|
|
198
|
+
(value.updated === undefined || typeof value.updated === 'string')
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isInfraManifestRecord(value: unknown): value is AppManifest['infra'] {
|
|
203
|
+
return isRecord(value) && Array.isArray(value.plugins);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isNavigatorSpec(value: unknown): value is NavigatorSpec {
|
|
207
|
+
return (
|
|
208
|
+
isRecord(value) &&
|
|
209
|
+
typeof value.type === 'string' &&
|
|
210
|
+
Array.isArray(value.routes) &&
|
|
211
|
+
(value.initialRouteName === undefined || typeof value.initialRouteName === 'string')
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isSettingsRecord(value: unknown): value is AppManifest['settings'] {
|
|
216
|
+
return (
|
|
217
|
+
isRecord(value) &&
|
|
218
|
+
(value.apiBaseUrl === undefined || typeof value.apiBaseUrl === 'string') &&
|
|
219
|
+
isLocalizationRecord(value.localization) &&
|
|
220
|
+
isAuthFlowConfig(value.authFlow)
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isLocalizationRecord(value: unknown): value is AppManifest['settings']['localization'] {
|
|
225
|
+
return (
|
|
226
|
+
isRecord(value) &&
|
|
227
|
+
typeof value.defaultLocale === 'string' &&
|
|
228
|
+
Array.isArray(value.locales) &&
|
|
229
|
+
value.locales.every((locale) => typeof locale === 'string')
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isAuthFlowConfig(value: unknown): value is AuthFlowConfig {
|
|
234
|
+
return (
|
|
235
|
+
isRecord(value) &&
|
|
236
|
+
typeof value.signInRoute === 'string' &&
|
|
237
|
+
typeof value.postSignInRoute === 'string' &&
|
|
238
|
+
(value.signUpRoute === undefined || typeof value.signUpRoute === 'string') &&
|
|
239
|
+
(value.signOutRoute === undefined || typeof value.signOutRoute === 'string') &&
|
|
240
|
+
(value.forgotPasswordRoute === undefined || typeof value.forgotPasswordRoute === 'string') &&
|
|
241
|
+
(value.otpRoute === undefined || typeof value.otpRoute === 'string') &&
|
|
242
|
+
(value.unauthorizedRoute === undefined || typeof value.unauthorizedRoute === 'string')
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
247
|
+
return typeof value === 'object' && value !== null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function isDirectory(filePath: string): Promise<boolean> {
|
|
251
|
+
try {
|
|
252
|
+
const stat = await fs.stat(filePath);
|
|
253
|
+
return stat.isDirectory();
|
|
254
|
+
} catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
260
|
+
try {
|
|
261
|
+
await fs.access(filePath);
|
|
262
|
+
return true;
|
|
263
|
+
} catch {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function getErrorMessage(error: unknown): string {
|
|
269
|
+
return error instanceof Error ? error.message : String(error);
|
|
270
|
+
}
|