@ofidj/generator-fidj 1.0.0 → 1.0.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/README.md +93 -58
- package/bin/create-fidj.cjs +54 -0
- package/generators/app/index.js +58 -189
- package/generators/app/templates/typescript/README.md +73 -0
- package/generators/app/templates/typescript/app.config.json +10 -0
- package/generators/app/templates/typescript/gitignore +5 -0
- package/generators/app/templates/typescript/package.json +25 -0
- package/generators/app/templates/typescript/public/fidj-logo.png +0 -0
- package/generators/app/templates/typescript/public/index.html +16 -0
- package/generators/app/templates/typescript/scripts/build.mjs +46 -0
- package/generators/app/templates/typescript/scripts/privacy-check.mjs +21 -0
- package/generators/app/templates/typescript/scripts/render-content.mjs +14 -0
- package/generators/app/templates/typescript/server/data-store.ts +123 -0
- package/generators/app/templates/typescript/server/index.ts +329 -0
- package/generators/app/templates/typescript/server/privacy-adapter.ts +21 -0
- package/generators/app/templates/typescript/src/content.ts +416 -0
- package/generators/app/templates/typescript/src/main.ts +243 -0
- package/generators/app/templates/typescript/src/style.css +639 -0
- package/generators/app/templates/typescript/test/server.test.mjs +287 -0
- package/generators/app/templates/typescript/tsconfig.json +15 -0
- package/lib/app-module.cjs +59 -0
- package/lib/scaffold.cjs +145 -0
- package/package.json +18 -12
- package/generators/app/templates/app2018/.travis.yml +0 -35
- package/generators/app/templates/app2018/LICENSE +0 -21
- package/generators/app/templates/app2018/Procfile +0 -1
- package/generators/app/templates/app2018/README.md +0 -49
- package/generators/app/templates/app2018/config.xml +0 -91
- package/generators/app/templates/app2018/gulpfile.js +0 -63
- package/generators/app/templates/app2018/ionic.config.json +0 -9
- package/generators/app/templates/app2018/package.json +0 -90
- package/generators/app/templates/app2018/platforms/README.md +0 -1
- package/generators/app/templates/app2018/resources/README.md +0 -6
- package/generators/app/templates/app2018/resources/icon.png +0 -0
- package/generators/app/templates/app2018/resources/marketing-01-ipad.png +0 -0
- package/generators/app/templates/app2018/resources/marketing-01-iphone.png +0 -0
- package/generators/app/templates/app2018/resources/marketing-02-ipad.png +0 -0
- package/generators/app/templates/app2018/resources/marketing-02-iphone.png +0 -0
- package/generators/app/templates/app2018/resources/marketing-03-ipad.png +0 -0
- package/generators/app/templates/app2018/resources/marketing-03-iphone.png +0 -0
- package/generators/app/templates/app2018/resources/marketing.json +0 -19
- package/generators/app/templates/app2018/resources/splash.png +0 -0
- package/generators/app/templates/app2018/src/app/app.component.ts +0 -88
- package/generators/app/templates/app2018/src/app/app.html +0 -19
- package/generators/app/templates/app2018/src/app/app.module.ts +0 -40
- package/generators/app/templates/app2018/src/app/app.scss +0 -16
- package/generators/app/templates/app2018/src/app/main.ts +0 -5
- package/generators/app/templates/app2018/src/app/shared/card.model.ts +0 -10
- package/generators/app/templates/app2018/src/app/shared/profile.service.ts +0 -46
- package/generators/app/templates/app2018/src/app/shared/shared.module.ts +0 -35
- package/generators/app/templates/app2018/src/app/shared/storage.service.ts +0 -11
- package/generators/app/templates/app2018/src/app/shared/version.const.ts +0 -5
- package/generators/app/templates/app2018/src/assets/icon/favicon.ico +0 -0
- package/generators/app/templates/app2018/src/assets/imgs/gg.png +0 -0
- package/generators/app/templates/app2018/src/assets/imgs/github.png +0 -0
- package/generators/app/templates/app2018/src/assets/imgs/login.svg +0 -5668
- package/generators/app/templates/app2018/src/assets/imgs/logo.png +0 -0
- package/generators/app/templates/app2018/src/index.html +0 -49
- package/generators/app/templates/app2018/src/manifest.json +0 -13
- package/generators/app/templates/app2018/src/pages/home/home.html +0 -56
- package/generators/app/templates/app2018/src/pages/home/home.scss +0 -3
- package/generators/app/templates/app2018/src/pages/home/home.ts +0 -126
- package/generators/app/templates/app2018/src/pages/login/login.html +0 -56
- package/generators/app/templates/app2018/src/pages/login/login.scss +0 -44
- package/generators/app/templates/app2018/src/pages/login/login.ts +0 -59
- package/generators/app/templates/app2018/src/pages/profile/profile.html +0 -33
- package/generators/app/templates/app2018/src/pages/profile/profile.scss +0 -3
- package/generators/app/templates/app2018/src/pages/profile/profile.ts +0 -36
- package/generators/app/templates/app2018/src/service-worker.js +0 -31
- package/generators/app/templates/app2018/src/theme/variables.scss +0 -88
- package/generators/app/templates/app2018/tests/karma.unit-tests.conf.js +0 -84
- package/generators/app/templates/app2018/tests/unit/app.test.js +0 -10
- package/generators/app/templates/app2018/tsconfig.json +0 -28
- package/generators/app/templates/app2018/tslint.json +0 -8
- package/generators/app/templates/app2018/web.js +0 -21
- package/generators/app/templates/app2021/.travis.yml +0 -32
- package/generators/app/templates/app2021/LICENSE +0 -21
- package/generators/app/templates/app2021/Procfile +0 -1
- package/generators/app/templates/app2021/README.md +0 -45
- package/generators/app/templates/app2021/angular.json +0 -188
- package/generators/app/templates/app2021/browserslist +0 -12
- package/generators/app/templates/app2021/ionic.config.json +0 -9
- package/generators/app/templates/app2021/karma.conf.js +0 -31
- package/generators/app/templates/app2021/package-lock.json +0 -5
- package/generators/app/templates/app2021/package.json +0 -77
- package/generators/app/templates/app2021/platforms/README.md +0 -1
- package/generators/app/templates/app2021/resources/README.md +0 -6
- package/generators/app/templates/app2021/resources/icon.png +0 -0
- package/generators/app/templates/app2021/resources/marketing-01-ipad.png +0 -0
- package/generators/app/templates/app2021/resources/marketing-01-iphone.png +0 -0
- package/generators/app/templates/app2021/resources/marketing-02-ipad.png +0 -0
- package/generators/app/templates/app2021/resources/marketing-02-iphone.png +0 -0
- package/generators/app/templates/app2021/resources/marketing-03-ipad.png +0 -0
- package/generators/app/templates/app2021/resources/marketing-03-iphone.png +0 -0
- package/generators/app/templates/app2021/resources/marketing.json +0 -19
- package/generators/app/templates/app2021/resources/splash.png +0 -0
- package/generators/app/templates/app2021/src/app/app-routing.module.ts +0 -25
- package/generators/app/templates/app2021/src/app/app.component.html +0 -3
- package/generators/app/templates/app2021/src/app/app.component.scss +0 -0
- package/generators/app/templates/app2021/src/app/app.component.spec.ts +0 -50
- package/generators/app/templates/app2021/src/app/app.component.ts +0 -28
- package/generators/app/templates/app2021/src/app/app.module.ts +0 -24
- package/generators/app/templates/app2021/src/app/content/content-routing.module.ts +0 -16
- package/generators/app/templates/app2021/src/app/content/content.module.ts +0 -20
- package/generators/app/templates/app2021/src/app/content/content.page.html +0 -31
- package/generators/app/templates/app2021/src/app/content/content.page.scss +0 -0
- package/generators/app/templates/app2021/src/app/content/content.page.ts +0 -26
- package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.html +0 -4
- package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.scss +0 -4
- package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.spec.ts +0 -24
- package/generators/app/templates/app2021/src/app/explore-container/explore-container.component.ts +0 -16
- package/generators/app/templates/app2021/src/app/explore-container/explore-container.module.ts +0 -14
- package/generators/app/templates/app2021/src/app/my/my-routing.module.ts +0 -36
- package/generators/app/templates/app2021/src/app/my/my.module.ts +0 -19
- package/generators/app/templates/app2021/src/app/my/my.page.html +0 -15
- package/generators/app/templates/app2021/src/app/my/my.page.scss +0 -1
- package/generators/app/templates/app2021/src/app/my/my.page.spec.ts +0 -26
- package/generators/app/templates/app2021/src/app/my/my.page.ts +0 -25
- package/generators/app/templates/app2021/src/app/profile/profile-routing.module.ts +0 -16
- package/generators/app/templates/app2021/src/app/profile/profile.module.ts +0 -20
- package/generators/app/templates/app2021/src/app/profile/profile.page.html +0 -49
- package/generators/app/templates/app2021/src/app/profile/profile.page.scss +0 -0
- package/generators/app/templates/app2021/src/app/profile/profile.page.spec.ts +0 -25
- package/generators/app/templates/app2021/src/app/profile/profile.page.ts +0 -44
- package/generators/app/templates/app2021/src/app/shared/const.ts +0 -3
- package/generators/app/templates/app2021/src/app/signin/signin-routing.module.ts +0 -16
- package/generators/app/templates/app2021/src/app/signin/signin.module.ts +0 -18
- package/generators/app/templates/app2021/src/app/signin/signin.page.html +0 -53
- package/generators/app/templates/app2021/src/app/signin/signin.page.scss +0 -36
- package/generators/app/templates/app2021/src/app/signin/signin.page.ts +0 -69
- package/generators/app/templates/app2021/src/assets/icon/favicon.png +0 -0
- package/generators/app/templates/app2021/src/assets/shapes.svg +0 -1
- package/generators/app/templates/app2021/src/environments/environment.prod.ts +0 -3
- package/generators/app/templates/app2021/src/environments/environment.ts +0 -16
- package/generators/app/templates/app2021/src/global.scss +0 -26
- package/generators/app/templates/app2021/src/index.html +0 -26
- package/generators/app/templates/app2021/src/main.ts +0 -12
- package/generators/app/templates/app2021/src/polyfills.ts +0 -67
- package/generators/app/templates/app2021/src/test.ts +0 -20
- package/generators/app/templates/app2021/src/theme/variables.scss +0 -232
- package/generators/app/templates/app2021/src/zone-flags.ts +0 -5
- package/generators/app/templates/app2021/tests/protractor.conf.js +0 -36
- package/generators/app/templates/app2021/tests/src/app.e2e-spec.ts +0 -14
- package/generators/app/templates/app2021/tests/src/app.po.ts +0 -11
- package/generators/app/templates/app2021/tests/tsconfig.json +0 -13
- package/generators/app/templates/app2021/tsconfig.app.json +0 -18
- package/generators/app/templates/app2021/tsconfig.json +0 -23
- package/generators/app/templates/app2021/tsconfig.spec.json +0 -18
- package/generators/app/templates/app2021/tslint.json +0 -149
- package/generators/app/templates/app2021/web.js +0 -21
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {execFile} from "node:child_process";
|
|
2
|
+
import {promisify} from "node:util";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { mkdtemp, rm, writeFile, readdir } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { createHmac } from "node:crypto";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
const { createApp, DataStore } = createRequire(import.meta.url)(
|
|
12
|
+
"../dist/server.cjs",
|
|
13
|
+
);
|
|
14
|
+
const listen = (server) =>
|
|
15
|
+
new Promise((resolve) =>
|
|
16
|
+
server.listen(0, "127.0.0.1", () =>
|
|
17
|
+
resolve(`http://127.0.0.1:${server.address().port}`),
|
|
18
|
+
),
|
|
19
|
+
);
|
|
20
|
+
const close = (server) => new Promise((resolve) => server.close(resolve));
|
|
21
|
+
const token = (subject, appId = "test-app") =>
|
|
22
|
+
`header.${Buffer.from(JSON.stringify({ sub: subject, name: subject + "@test.local", aud: appId, roles: ["Owner"] })).toString("base64url")}.signature`;
|
|
23
|
+
|
|
24
|
+
test("enforces live app roles, identity isolation and scoped privacy over HTTP", async () => {
|
|
25
|
+
const directory = await mkdtemp(join(tmpdir(), "fidj-data-test-"));
|
|
26
|
+
const adapterKey = "test-only-privacy-adapter-secret-32-chars";
|
|
27
|
+
let appUrl;
|
|
28
|
+
const adapterCall = async (
|
|
29
|
+
operation,
|
|
30
|
+
subject,
|
|
31
|
+
requestId = "departure-maya",
|
|
32
|
+
overrides = {},
|
|
33
|
+
) => {
|
|
34
|
+
const data = { operation, subject, appId: "test-app", requestId };
|
|
35
|
+
const timestamp = String(Date.now());
|
|
36
|
+
const signature = createHmac("sha256", adapterKey)
|
|
37
|
+
.update(timestamp + "." + JSON.stringify(data))
|
|
38
|
+
.digest("hex");
|
|
39
|
+
return fetch(appUrl + "/fidj/privacy", {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
"x-fidj-timestamp": timestamp,
|
|
44
|
+
"x-fidj-signature": signature,
|
|
45
|
+
...overrides,
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify(data),
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
const maya = token("maya");
|
|
51
|
+
const sam = token("sam");
|
|
52
|
+
const assignments = new Map([
|
|
53
|
+
[maya, ["Free"]],
|
|
54
|
+
[sam, ["Editor"]],
|
|
55
|
+
]);
|
|
56
|
+
const consents = new Map();
|
|
57
|
+
let outage = false;
|
|
58
|
+
const issuer = createServer(async (req, res) => {
|
|
59
|
+
const bearer = req.headers.authorization?.slice(7);
|
|
60
|
+
const roles = assignments.get(bearer);
|
|
61
|
+
res.setHeader("Content-Type", "application/json");
|
|
62
|
+
if (outage) {
|
|
63
|
+
res.writeHead(503);
|
|
64
|
+
return res.end("{}");
|
|
65
|
+
}
|
|
66
|
+
if (!roles) {
|
|
67
|
+
res.writeHead(403);
|
|
68
|
+
return res.end("{}");
|
|
69
|
+
}
|
|
70
|
+
if (req.url === "/apps/test-app/me")
|
|
71
|
+
return res.end(
|
|
72
|
+
JSON.stringify({ roles: roles.map((type) => ({ type })) }),
|
|
73
|
+
);
|
|
74
|
+
if (req.url === "/me/apps/test-app/consents/history")
|
|
75
|
+
return res.end('{"history":[]}');
|
|
76
|
+
if (req.url === "/me/apps/test-app/consents") {
|
|
77
|
+
if (req.method === "PUT") {
|
|
78
|
+
let text = "";
|
|
79
|
+
for await (const chunk of req) text += chunk;
|
|
80
|
+
consents.set(bearer, JSON.parse(text));
|
|
81
|
+
}
|
|
82
|
+
return res.end(
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
...(consents.get(bearer) || { terms: true }),
|
|
85
|
+
appDataConnected: true,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (req.url === "/me/apps/test-app/export")
|
|
90
|
+
return res.end(JSON.stringify({ consent: consents.get(bearer) }));
|
|
91
|
+
if (req.url === "/me/apps/test-app" && req.method === "DELETE") {
|
|
92
|
+
const erased = await adapterCall(
|
|
93
|
+
"erase",
|
|
94
|
+
JSON.parse(Buffer.from(bearer.split(".")[1], "base64url")).sub,
|
|
95
|
+
);
|
|
96
|
+
if (!erased.ok) {
|
|
97
|
+
res.writeHead(202);
|
|
98
|
+
return res.end('{"status":"pending"}');
|
|
99
|
+
}
|
|
100
|
+
assignments.delete(bearer);
|
|
101
|
+
return res.end('{"status":"completed"}');
|
|
102
|
+
}
|
|
103
|
+
res.writeHead(404);
|
|
104
|
+
res.end("{}");
|
|
105
|
+
});
|
|
106
|
+
const apiEndpoint = await listen(issuer);
|
|
107
|
+
const settings = {
|
|
108
|
+
appId: "test-app",
|
|
109
|
+
apiEndpoint,
|
|
110
|
+
dashboardUrl: "https://fidj.ovh",
|
|
111
|
+
title: "Test app",
|
|
112
|
+
localDemo: false,
|
|
113
|
+
};
|
|
114
|
+
const persistence = new DataStore(directory, "test-app");
|
|
115
|
+
const erase = persistence.erase.bind(persistence);
|
|
116
|
+
let storageUnavailable = false;
|
|
117
|
+
persistence.erase = async (...args) => {
|
|
118
|
+
if (storageUnavailable) throw new Error("Simulated storage outage");
|
|
119
|
+
return erase(...args);
|
|
120
|
+
};
|
|
121
|
+
let app = createApp(settings, {
|
|
122
|
+
dataDir: directory,
|
|
123
|
+
adapterKey,
|
|
124
|
+
store: persistence,
|
|
125
|
+
});
|
|
126
|
+
let url = await listen(app);
|
|
127
|
+
appUrl = url;
|
|
128
|
+
const call = (path, bearer = maya, method = "GET", data) =>
|
|
129
|
+
fetch(url + "/api/" + path, {
|
|
130
|
+
method,
|
|
131
|
+
headers: {
|
|
132
|
+
Authorization: "Bearer " + bearer,
|
|
133
|
+
"Content-Type": "application/json",
|
|
134
|
+
},
|
|
135
|
+
body: data === undefined ? undefined : JSON.stringify(data),
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
assert.equal((await fetch(url + "/api/notes")).status, 401);
|
|
139
|
+
assert.equal(
|
|
140
|
+
(await call("session", token("maya", "other-app"))).status,
|
|
141
|
+
401,
|
|
142
|
+
);
|
|
143
|
+
assert.equal((await call("session", maya + "tampered")).status, 401);
|
|
144
|
+
assert.equal(
|
|
145
|
+
(await call("notes", maya, "POST", { title: "Hello", body: "Private" }))
|
|
146
|
+
.status,
|
|
147
|
+
403,
|
|
148
|
+
);
|
|
149
|
+
assignments.set(maya, ["Editor"]);
|
|
150
|
+
assert.equal(
|
|
151
|
+
(await call("notes", maya, "POST", { title: "Hello", body: "Private" }))
|
|
152
|
+
.status,
|
|
153
|
+
201,
|
|
154
|
+
);
|
|
155
|
+
assert.equal((await (await call("notes", sam)).json()).notes.length, 0);
|
|
156
|
+
assert.equal((await (await call("notes")).json()).notes.length, 1);
|
|
157
|
+
await call("notes", sam, "POST", { title: "Sam only", body: "Preserve" });
|
|
158
|
+
await close(app);
|
|
159
|
+
app = createApp(settings, {
|
|
160
|
+
dataDir: directory,
|
|
161
|
+
adapterKey,
|
|
162
|
+
store: persistence,
|
|
163
|
+
});
|
|
164
|
+
url = await listen(app);
|
|
165
|
+
appUrl = url;
|
|
166
|
+
assert.equal((await (await call("notes")).json()).notes[0].title, "Hello");
|
|
167
|
+
assert.equal(
|
|
168
|
+
(
|
|
169
|
+
await adapterCall("export", "maya", "export-test", {
|
|
170
|
+
"x-fidj-signature": "0".repeat(64),
|
|
171
|
+
})
|
|
172
|
+
).status,
|
|
173
|
+
401,
|
|
174
|
+
);
|
|
175
|
+
assert.equal(
|
|
176
|
+
(
|
|
177
|
+
await adapterCall("export", "maya", "export-test", {
|
|
178
|
+
"x-fidj-timestamp": "1",
|
|
179
|
+
})
|
|
180
|
+
).status,
|
|
181
|
+
401,
|
|
182
|
+
);
|
|
183
|
+
assert.equal(
|
|
184
|
+
(await (await adapterCall("export", "maya", "export-test")).json()).data
|
|
185
|
+
.notes.length,
|
|
186
|
+
1,
|
|
187
|
+
);
|
|
188
|
+
assert.equal(
|
|
189
|
+
(await (await adapterCall("export", "sam", "export-sam")).json()).data
|
|
190
|
+
.notes.length,
|
|
191
|
+
1,
|
|
192
|
+
);
|
|
193
|
+
assert.equal((await fetch(url + "/.fidj-data/data.json")).status, 404);
|
|
194
|
+
const readiness = await (await adapterCall("check", "readiness-check", "check-test")).json();
|
|
195
|
+
assert.equal(readiness.storage, "ready");
|
|
196
|
+
assert.deepEqual(readiness.capabilities, ["export", "erase"]);
|
|
197
|
+
const checked = await promisify(execFile)(process.execPath, ["scripts/privacy-check.mjs"], {env: {...process.env, FIDJ_APP_ID: "test-app", FIDJ_APP_URL: url, FIDJ_PRIVACY_ADAPTER_KEY: adapterKey}});
|
|
198
|
+
assert.match(checked.stdout, /Ready:/);
|
|
199
|
+
await assert.rejects(promisify(execFile)(process.execPath, ["scripts/privacy-check.mjs"], {env: {...process.env, FIDJ_APP_ID: "test-app", FIDJ_APP_URL: url, FIDJ_PRIVACY_ADAPTER_KEY: "wrong-key-that-is-long-enough-for-check"}}));
|
|
200
|
+
assert.equal(
|
|
201
|
+
(await (await fetch(url + "/api/config")).json()).adapterKey,
|
|
202
|
+
undefined,
|
|
203
|
+
);
|
|
204
|
+
assignments.set(maya, ["Free"]);
|
|
205
|
+
assert.equal(
|
|
206
|
+
(await call("notes", maya, "POST", { title: "Denied", body: "" })).status,
|
|
207
|
+
403,
|
|
208
|
+
);
|
|
209
|
+
assert.equal(
|
|
210
|
+
(await call("privacy", maya, "PUT", { analytics: true })).status,
|
|
211
|
+
200,
|
|
212
|
+
);
|
|
213
|
+
assert.equal(consents.get(maya).analytics, true);
|
|
214
|
+
assert.equal(consents.has(sam), false);
|
|
215
|
+
assert.equal(
|
|
216
|
+
(await (await call("privacy/export")).json()).app.notes.length,
|
|
217
|
+
1,
|
|
218
|
+
);
|
|
219
|
+
outage = true;
|
|
220
|
+
assert.equal((await call("notes")).status, 503);
|
|
221
|
+
outage = false;
|
|
222
|
+
assert.equal(
|
|
223
|
+
(await call("privacy/leave", maya, "DELETE", { confirm: "other-app" }))
|
|
224
|
+
.status,
|
|
225
|
+
400,
|
|
226
|
+
);
|
|
227
|
+
storageUnavailable = true;
|
|
228
|
+
assert.equal((await adapterCall("erase", "maya")).status, 500);
|
|
229
|
+
assert.equal((await (await call("notes")).json()).notes.length, 1);
|
|
230
|
+
storageUnavailable = false;
|
|
231
|
+
await writeFile(
|
|
232
|
+
join(directory, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.tmp"),
|
|
233
|
+
"Interrupted private data copy",
|
|
234
|
+
);
|
|
235
|
+
assert.equal(
|
|
236
|
+
(await call("privacy/leave", maya, "DELETE", { confirm: "test-app" }))
|
|
237
|
+
.status,
|
|
238
|
+
200,
|
|
239
|
+
);
|
|
240
|
+
assert.equal((await call("notes")).status, 401);
|
|
241
|
+
assert.equal(
|
|
242
|
+
(await readdir(directory)).filter((name) => name.endsWith(".tmp")).length,
|
|
243
|
+
0,
|
|
244
|
+
);
|
|
245
|
+
assignments.set(maya, ["Free"]);
|
|
246
|
+
assert.equal((await (await call("notes")).json()).notes.length, 0);
|
|
247
|
+
assert.equal((await call("session", sam)).status, 200);
|
|
248
|
+
assert.equal(
|
|
249
|
+
(await (await call("notes", sam)).json()).notes[0].title,
|
|
250
|
+
"Sam only",
|
|
251
|
+
);
|
|
252
|
+
assignments.set(maya, ["Editor"]);
|
|
253
|
+
await call("notes", maya, "POST", {
|
|
254
|
+
title: "New membership",
|
|
255
|
+
body: "Keep this",
|
|
256
|
+
});
|
|
257
|
+
await close(app);
|
|
258
|
+
app = createApp(settings, {
|
|
259
|
+
dataDir: directory,
|
|
260
|
+
adapterKey,
|
|
261
|
+
store: persistence,
|
|
262
|
+
});
|
|
263
|
+
url = await listen(app);
|
|
264
|
+
appUrl = url;
|
|
265
|
+
assert.equal((await adapterCall("erase", "maya")).status, 200);
|
|
266
|
+
assert.equal(
|
|
267
|
+
(await (await call("notes")).json()).notes[0].title,
|
|
268
|
+
"New membership",
|
|
269
|
+
);
|
|
270
|
+
assert.equal((await adapterCall("erase", "sam")).status, 500);
|
|
271
|
+
const store = new DataStore(directory, "test-app");
|
|
272
|
+
await assert.rejects(
|
|
273
|
+
store.add(
|
|
274
|
+
"maya",
|
|
275
|
+
{ id: "denied", title: "Race", body: "", createdAt: "" },
|
|
276
|
+
async () => {
|
|
277
|
+
throw new Error("Revoked during operation");
|
|
278
|
+
},
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
assert.equal((await store.notes("maya")).length, 1);
|
|
282
|
+
} finally {
|
|
283
|
+
await close(app);
|
|
284
|
+
await close(issuer);
|
|
285
|
+
await rm(directory, { recursive: true, force: true });
|
|
286
|
+
}
|
|
287
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"lib": ["ES2022", "DOM"],
|
|
10
|
+
"types": ["node"],
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"resolveJsonModule": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts", "server/**/*.ts"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
|
|
4
|
+
function inspectModule(source, entry = "index.html", destination) {
|
|
5
|
+
const root = fs.realpathSync(path.resolve(source));
|
|
6
|
+
const output = path.resolve(destination);
|
|
7
|
+
if (!fs.statSync(root).isDirectory())
|
|
8
|
+
throw new Error("Module must be a built website directory.");
|
|
9
|
+
if (
|
|
10
|
+
output === root ||
|
|
11
|
+
output.startsWith(root + path.sep) ||
|
|
12
|
+
root.startsWith(output + path.sep)
|
|
13
|
+
)
|
|
14
|
+
throw new Error(
|
|
15
|
+
"Module source and generated destination must not contain each other.",
|
|
16
|
+
);
|
|
17
|
+
if (/^(?:[a-z]+:|\/\/)/i.test(entry))
|
|
18
|
+
throw new Error("Module entry must be relative.");
|
|
19
|
+
const url = new URL(entry, "https://module.invalid/module/");
|
|
20
|
+
if (
|
|
21
|
+
url.origin !== "https://module.invalid" ||
|
|
22
|
+
!url.pathname.startsWith("/module/") ||
|
|
23
|
+
url.search ||
|
|
24
|
+
entry.startsWith("/")
|
|
25
|
+
)
|
|
26
|
+
throw new Error(
|
|
27
|
+
"Module entry must be a relative HTML path inside the module.",
|
|
28
|
+
);
|
|
29
|
+
const file = decodeURIComponent(url.pathname.slice("/module/".length));
|
|
30
|
+
if (
|
|
31
|
+
!file.endsWith(".html") ||
|
|
32
|
+
file.split("/").some((part) => part === "..") ||
|
|
33
|
+
file.includes("\\")
|
|
34
|
+
)
|
|
35
|
+
throw new Error(
|
|
36
|
+
"Module entry must be a relative HTML path inside the module.",
|
|
37
|
+
);
|
|
38
|
+
if (!fs.statSync(path.join(root, file)).isFile())
|
|
39
|
+
throw new Error("Module entry does not exist.");
|
|
40
|
+
if (!fs.readFileSync(path.join(root, file), "utf8").includes("</head>"))
|
|
41
|
+
throw new Error("Module entry must have an HTML head.");
|
|
42
|
+
const visit = (directory) => {
|
|
43
|
+
for (const item of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
44
|
+
if (item.isSymbolicLink())
|
|
45
|
+
throw new Error("Built modules must not contain symbolic links.");
|
|
46
|
+
if (
|
|
47
|
+
[".git", "node_modules", ".env"].includes(item.name) ||
|
|
48
|
+
item.name.startsWith(".env.")
|
|
49
|
+
)
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Supply only the module's public build output, without environment or dependency files.",
|
|
52
|
+
);
|
|
53
|
+
if (item.isDirectory()) visit(path.join(directory, item.name));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
visit(root);
|
|
57
|
+
return { root, file, entry: "./module/" + file + url.hash };
|
|
58
|
+
}
|
|
59
|
+
module.exports = { inspectModule };
|
package/lib/scaffold.cjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { inspectModule } = require("./app-module.cjs");
|
|
4
|
+
const templateRoot = path.join(
|
|
5
|
+
__dirname,
|
|
6
|
+
"../generators/app/templates/typescript",
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
function scaffold(destination, options) {
|
|
10
|
+
const anonymous = options.anonymous ?? true;
|
|
11
|
+
if (![true, false, "true", "false"].includes(anonymous))
|
|
12
|
+
throw new Error("--anonymous must be true or false.");
|
|
13
|
+
const name = options.name || path.basename(path.resolve(destination));
|
|
14
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name))
|
|
15
|
+
throw new Error(
|
|
16
|
+
"Use a lowercase project name with letters, numbers and hyphens.",
|
|
17
|
+
);
|
|
18
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(options.appId || ""))
|
|
19
|
+
throw new Error("Supply your app’s public fidjId with --app-id.");
|
|
20
|
+
const api = new URL(options.apiEndpoint || "https://api.sandbox.fidj.ovh/v3");
|
|
21
|
+
if (
|
|
22
|
+
api.username ||
|
|
23
|
+
api.password ||
|
|
24
|
+
api.search ||
|
|
25
|
+
api.hash ||
|
|
26
|
+
!["http:", "https:"].includes(api.protocol)
|
|
27
|
+
)
|
|
28
|
+
throw new Error(
|
|
29
|
+
"Use an API URL without credentials, a query or a fragment.",
|
|
30
|
+
);
|
|
31
|
+
if (
|
|
32
|
+
api.protocol === "http:" &&
|
|
33
|
+
!["localhost", "127.0.0.1", "[::1]"].includes(api.hostname)
|
|
34
|
+
)
|
|
35
|
+
throw new Error("Use HTTPS for a remote API.");
|
|
36
|
+
if (options.oidcIssuer) {
|
|
37
|
+
const issuer = new URL(options.oidcIssuer);
|
|
38
|
+
if (issuer.origin !== api.origin || issuer.pathname !== "/oidc" || issuer.hash || issuer.search || issuer.username || issuer.password) throw new Error("OIDC issuer must share the API origin and use /oidc.");
|
|
39
|
+
if (options.content === undefined && !options.module) throw new Error("OIDC currently requires a content app or console module.");
|
|
40
|
+
}
|
|
41
|
+
const title = options.title || name;
|
|
42
|
+
if (/[\r\n]/.test(title)) throw new Error("Title must be one line.");
|
|
43
|
+
if (
|
|
44
|
+
options.domain &&
|
|
45
|
+
!/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(options.domain)
|
|
46
|
+
)
|
|
47
|
+
throw new Error("Domain must be a hostname, without a scheme or path.");
|
|
48
|
+
const dir = path.resolve(destination);
|
|
49
|
+
const appModule = options.module
|
|
50
|
+
? inspectModule(options.module, options.moduleEntry, dir)
|
|
51
|
+
: null;
|
|
52
|
+
if (options.moduleEntry && !appModule)
|
|
53
|
+
throw new Error("--module-entry requires --module.");
|
|
54
|
+
if (options.replace && fs.existsSync(dir)) {
|
|
55
|
+
if (!fs.existsSync(path.join(dir, ".fidj-generated")))
|
|
56
|
+
throw new Error(
|
|
57
|
+
"Replacement requires a .fidj-generated marker in the destination.",
|
|
58
|
+
);
|
|
59
|
+
fs.rmSync(dir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length)
|
|
62
|
+
throw new Error(
|
|
63
|
+
"Destination must be empty. Existing projects are never overwritten.",
|
|
64
|
+
);
|
|
65
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
66
|
+
fs.cpSync(templateRoot, dir, { recursive: true });
|
|
67
|
+
const pkg = JSON.parse(
|
|
68
|
+
fs.readFileSync(path.join(dir, "package.json"), "utf8"),
|
|
69
|
+
);
|
|
70
|
+
pkg.name = name;
|
|
71
|
+
if (options.sdkPath) {
|
|
72
|
+
const sdkPath = path.resolve(options.sdkPath);
|
|
73
|
+
const sdk = JSON.parse(
|
|
74
|
+
fs.readFileSync(path.join(sdkPath, "package.json"), "utf8"),
|
|
75
|
+
);
|
|
76
|
+
if (sdk.name !== "@ofidj/node")
|
|
77
|
+
throw new Error("SDK path must contain a built @ofidj/node package.");
|
|
78
|
+
pkg.dependencies["@ofidj/node"] = "file:" + sdkPath;
|
|
79
|
+
}
|
|
80
|
+
fs.writeFileSync(
|
|
81
|
+
path.join(dir, "package.json"),
|
|
82
|
+
JSON.stringify(pkg, null, 2) + "\n",
|
|
83
|
+
);
|
|
84
|
+
fs.renameSync(path.join(dir, "gitignore"), path.join(dir, ".gitignore"));
|
|
85
|
+
const settings = `FIDJ_APP_ID=${options.appId}\nFIDJ_API_ENDPOINT=${api.href.replace(/\/$/, "")}\nFIDJ_DASHBOARD_URL=https://fidj.ovh\nAPP_TITLE=${title}\nPORT=8200\nHOST=127.0.0.1\nLOCAL_DEMO=false\nFIDJ_DATA_DIR=../.fidj-data/${name}\nFIDJ_PRIVACY_ADAPTER_KEY=\n`;
|
|
86
|
+
const localApi = options.local
|
|
87
|
+
? "http://localhost:3201/v3"
|
|
88
|
+
: api.href.replace(/\/$/, "");
|
|
89
|
+
const configuration = {
|
|
90
|
+
appId: options.appId,
|
|
91
|
+
oidcIssuer: options.oidcIssuer || null,
|
|
92
|
+
apiEndpoint: localApi,
|
|
93
|
+
dashboardUrl: options.local ? "http://localhost:4200" : "https://fidj.ovh",
|
|
94
|
+
title,
|
|
95
|
+
localDemo: Boolean(options.local),
|
|
96
|
+
allowAnonymous: anonymous === true || anonymous === "true",
|
|
97
|
+
welcome: options.welcome || title,
|
|
98
|
+
description:
|
|
99
|
+
options.description ||
|
|
100
|
+
"A space to explore, with an account that puts you in control.",
|
|
101
|
+
content: appModule ? "" : options.content,
|
|
102
|
+
moduleEntry: appModule?.entry || "",
|
|
103
|
+
domain: options.domain,
|
|
104
|
+
};
|
|
105
|
+
if (appModule) {
|
|
106
|
+
fs.cpSync(appModule.root, path.join(dir, "public/module"), {
|
|
107
|
+
recursive: true,
|
|
108
|
+
});
|
|
109
|
+
const entryFile = path.join(dir, "public/module", appModule.file);
|
|
110
|
+
const relativeSignin =
|
|
111
|
+
path.posix.relative(
|
|
112
|
+
path.posix.dirname("module/" + appModule.file),
|
|
113
|
+
"index.html",
|
|
114
|
+
) + "#/signin";
|
|
115
|
+
const html = fs.readFileSync(entryFile, "utf8");
|
|
116
|
+
if (!html.includes("</head>"))
|
|
117
|
+
throw new Error("Module entry must have an HTML head.");
|
|
118
|
+
fs.writeFileSync(
|
|
119
|
+
entryFile,
|
|
120
|
+
html.replace(
|
|
121
|
+
"</head>",
|
|
122
|
+
`<meta name="fidj-signin" content="${relativeSignin}"></head>`,
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
fs.writeFileSync(
|
|
127
|
+
path.join(dir, "app.config.json"),
|
|
128
|
+
JSON.stringify(configuration, null, 2) + "\n",
|
|
129
|
+
);
|
|
130
|
+
const env = settings
|
|
131
|
+
.replace(api.href.replace(/\/$/, ""), localApi)
|
|
132
|
+
.replace(
|
|
133
|
+
"FIDJ_DASHBOARD_URL=https://fidj.ovh",
|
|
134
|
+
`FIDJ_DASHBOARD_URL=${configuration.dashboardUrl}`,
|
|
135
|
+
)
|
|
136
|
+
.replace("LOCAL_DEMO=false", `LOCAL_DEMO=${configuration.localDemo}`);
|
|
137
|
+
fs.writeFileSync(path.join(dir, ".env.example"), env);
|
|
138
|
+
fs.writeFileSync(path.join(dir, ".env"), env);
|
|
139
|
+
fs.writeFileSync(
|
|
140
|
+
path.join(dir, ".fidj-generated"),
|
|
141
|
+
"Disposable generator output.\n",
|
|
142
|
+
);
|
|
143
|
+
return dir;
|
|
144
|
+
}
|
|
145
|
+
module.exports = { scaffold };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ofidj/generator-fidj",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Generate a TypeScript app with Fidj sign-in, live server-side roles and per-app privacy.",
|
|
5
5
|
"homepage": "https://fidj.ovh",
|
|
6
6
|
"bugs": {
|
|
7
7
|
"url": "https://github.com/ofidj/generator-fidj/issues"
|
|
@@ -10,9 +10,13 @@
|
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
|
-
"
|
|
13
|
+
"bin",
|
|
14
|
+
"lib",
|
|
15
|
+
"generators",
|
|
16
|
+
"!generators/app/templates/app2018",
|
|
17
|
+
"!generators/app/templates/app2021"
|
|
14
18
|
],
|
|
15
|
-
"main": "
|
|
19
|
+
"main": "./lib/scaffold.cjs",
|
|
16
20
|
"keywords": [
|
|
17
21
|
"fidj",
|
|
18
22
|
"angular",
|
|
@@ -20,13 +24,9 @@
|
|
|
20
24
|
"yeoman-generator"
|
|
21
25
|
],
|
|
22
26
|
"dependencies": {
|
|
23
|
-
"
|
|
24
|
-
"yeoman-generator": "^4.12.0",
|
|
25
|
-
"yosay": "^2.0.2"
|
|
26
|
-
},
|
|
27
|
-
"devDependencies": {
|
|
28
|
-
"tslint": "^6.1.3"
|
|
27
|
+
"yeoman-generator": "^4.12.0"
|
|
29
28
|
},
|
|
29
|
+
"devDependencies": {},
|
|
30
30
|
"eslintConfig": {
|
|
31
31
|
"extends": "xo-space",
|
|
32
32
|
"env": {
|
|
@@ -35,7 +35,13 @@
|
|
|
35
35
|
},
|
|
36
36
|
"repository": "https://github.com/ofidj/generator-fidj.git",
|
|
37
37
|
"scripts": {
|
|
38
|
-
"test": "
|
|
38
|
+
"test": "node --test test/*.test.cjs"
|
|
39
|
+
},
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"bin": {
|
|
42
|
+
"create-fidj": "bin/create-fidj.cjs"
|
|
39
43
|
},
|
|
40
|
-
"
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=22"
|
|
46
|
+
}
|
|
41
47
|
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
language:
|
|
2
|
-
- node_js
|
|
3
|
-
node_js:
|
|
4
|
-
- 6.11.5
|
|
5
|
-
branches:
|
|
6
|
-
only:
|
|
7
|
-
- master
|
|
8
|
-
install:
|
|
9
|
-
- git config remote.origin.fetch +refs/heads/*:refs/remotes/origin/*
|
|
10
|
-
- git fetch --tags --depth=10000
|
|
11
|
-
- npm install
|
|
12
|
-
- npm version patch -m "[ci skip] version bumped %s"
|
|
13
|
-
script:
|
|
14
|
-
- npm test
|
|
15
|
-
- npm run build
|
|
16
|
-
after_success:
|
|
17
|
-
- git remote add upstream "https://${GH_TOKEN}@github.com/${GH_REPO}.git"
|
|
18
|
-
- git add .
|
|
19
|
-
- git commit -m "[ci skip] version ok"
|
|
20
|
-
- git push upstream HEAD:master
|
|
21
|
-
- rm -r src
|
|
22
|
-
- rm -r tests
|
|
23
|
-
- rm -r coverage
|
|
24
|
-
- rm -r node_modules
|
|
25
|
-
- rm www/.gitignore
|
|
26
|
-
deploy:
|
|
27
|
-
provider: pages
|
|
28
|
-
skip-cleanup: true
|
|
29
|
-
github-token: $GH_TOKEN
|
|
30
|
-
keep-history: true
|
|
31
|
-
on:
|
|
32
|
-
branch: master
|
|
33
|
-
env:
|
|
34
|
-
global:
|
|
35
|
-
- GH_REPO: "<%= appUserName %>/<%= appName %>"
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2018 fidj.ovh team
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
web: node web.js
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# <%= appName %>
|
|
2
|
-
|
|
3
|
-
> <%= appDescription %>
|
|
4
|
-
|
|
5
|
-
## Prerequisite
|
|
6
|
-
|
|
7
|
-
Look at [mat's gist](https://gist.github.com/mlefree/2156f66dfb441f107bef157dde56a836),
|
|
8
|
-
in order to check your node, cordova, ionic and yeoman installation.
|
|
9
|
-
|
|
10
|
-
[![Fidj.ovh Status][fidj-image]][fidj-url]
|
|
11
|
-
[![Build Status][travis-image]][travis-url]
|
|
12
|
-
|
|
13
|
-
[![video][youtube-img]][youtube-video]
|
|
14
|
-
|
|
15
|
-
## Build it
|
|
16
|
-
|
|
17
|
-
Clone and install the project
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
npm install
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
and then launch on your device
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
npm run ios
|
|
27
|
-
# then ready to launch your app (./platforms/ios/*.xcodeproj) in XCode
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
## Deploy
|
|
31
|
-
|
|
32
|
-
Follow this https://ionicframework.com/docs/intro/deploying.
|
|
33
|
-
|
|
34
|
-
### For test on Github or Heroku
|
|
35
|
-
|
|
36
|
-
It's up to you if you want to use your www generated folder as static page.You can also use the [fidj-sandbox](https://sss) before any production; 2 variables are available for that :
|
|
37
|
-
|
|
38
|
-
```bash
|
|
39
|
-
FIDJ_APP_ID # force your fidj app Id with your sandbox's one
|
|
40
|
-
FIDJ_APP_PROD # 'true' by default, 'false' to test it on sandbox
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
[fidj-image]: https://fidj.ovh/_/<%= appName %>/badges/github.svg
|
|
44
|
-
[fidj-url]: https://fidj.ovh/_/<%= appName %>
|
|
45
|
-
[youtube-img]: http://img.youtube.com/vi/0FbnCWWg_NY/0.jpg
|
|
46
|
-
[youtube-video]: https://www.youtube.com/embed/0FbnCWWg_NY?autoplay=true
|
|
47
|
-
[travis-image]: https://travis-ci.org/<%=appUserName %>/<%= appName %>.svg?branch=master
|
|
48
|
-
[travis-url]: https://travis-ci.org/<%=appUserName %>/<%= appName %>
|
|
49
|
-
|