@youcan/app 1.1.0-beta.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 YouCan
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.
@@ -0,0 +1,5 @@
1
+ import { AppCommand } from '@/util/theme-command';
2
+ declare class Dev extends AppCommand {
3
+ run(): Promise<any>;
4
+ }
5
+ export default Dev;
@@ -0,0 +1,46 @@
1
+ import { Session, Tasks, Http, Env, Filesystem, Path } from '@youcan/cli-kit';
2
+ import { AppCommand } from '../../../util/theme-command.js';
3
+ import { load } from '../../../util/app-loader.js';
4
+ import { APP_CONFIG_FILENAME } from '../../../constants.js';
5
+ import { bootExtensionWorker } from '../../services/dev/workers/index.js';
6
+
7
+ class Dev extends AppCommand {
8
+ async run() {
9
+ const app = await load();
10
+ const session = await Session.authenticate(this);
11
+ await Tasks.run({ cmd: this }, [
12
+ {
13
+ title: 'Creating draft app..',
14
+ skip() {
15
+ return app.config.id != null;
16
+ },
17
+ async task() {
18
+ const res = await Http.post(`${Env.apiHostname()}/apps/draft/create`, {
19
+ headers: { Authorization: `Bearer ${session.access_token}` },
20
+ body: JSON.stringify({ name: app.config.name }),
21
+ });
22
+ app.config = {
23
+ name: res.name,
24
+ id: res.id,
25
+ url: res.url,
26
+ oauth: {
27
+ scopes: res.scopes,
28
+ client_id: res.client_id,
29
+ },
30
+ };
31
+ await Filesystem.writeJsonFile(Path.join(app.root, APP_CONFIG_FILENAME), app.config);
32
+ },
33
+ },
34
+ {
35
+ title: 'Preparing dev workers..',
36
+ async task(ctx) {
37
+ const promises = app.extensions.map(async (ext) => await bootExtensionWorker(ctx.cmd, app, ext));
38
+ const workers = await Promise.all(promises);
39
+ await Promise.all(workers.map(async (worker) => await worker.run()));
40
+ },
41
+ },
42
+ ]);
43
+ }
44
+ }
45
+
46
+ export { Dev as default };
@@ -0,0 +1,5 @@
1
+ import { AppCommand } from '@/util/theme-command';
2
+ declare class GenerateExtension extends AppCommand {
3
+ run(): Promise<any>;
4
+ }
5
+ export default GenerateExtension;
@@ -0,0 +1,57 @@
1
+ import { Path, Filesystem, String, Tasks } from '@youcan/cli-kit';
2
+ import { AppCommand } from '../../../../util/theme-command.js';
3
+ import extensions from '../../../services/generate/extensions/index.js';
4
+ import { ensureExtensionDirectoryExists, initThemeExtension } from '../../../services/generate/generate.js';
5
+ import { APP_CONFIG_FILENAME } from '../../../../constants.js';
6
+
7
+ class GenerateExtension extends AppCommand {
8
+ async run() {
9
+ const filepath = Path.resolve(Path.cwd(), APP_CONFIG_FILENAME);
10
+ const app = await Filesystem.readJsonFile(filepath);
11
+ const { identifier } = await this.prompt({
12
+ name: 'identifier',
13
+ message: 'Extension type?',
14
+ type: 'select',
15
+ choices: extensions.map(ext => ({
16
+ title: ext.name,
17
+ value: ext.identifier,
18
+ description: ext.description,
19
+ })),
20
+ });
21
+ // TODO: prompt for extension type and flavor
22
+ const extension = extensions.find(ext => ext.identifier === identifier);
23
+ const type = extension.types[0];
24
+ const flavor = type.flavors[0];
25
+ const { name } = await this.prompt({
26
+ name: 'name',
27
+ message: 'Extension name?',
28
+ type: 'text',
29
+ initial: extension.name,
30
+ validate: prev => prev.length >= 3,
31
+ format: name => String.hyphenate(name),
32
+ });
33
+ await Tasks.run({}, [
34
+ {
35
+ title: 'Validating extension options..',
36
+ async task(ctx) {
37
+ ctx.directory = await ensureExtensionDirectoryExists(name);
38
+ },
39
+ },
40
+ {
41
+ title: 'Initializing extension..',
42
+ async task(ctx) {
43
+ await initThemeExtension({
44
+ app,
45
+ name,
46
+ type,
47
+ flavor,
48
+ directory: ctx.directory,
49
+ });
50
+ },
51
+ },
52
+ ]);
53
+ this.output.info(`Extension '${name}' successfully generated.`);
54
+ }
55
+ }
56
+
57
+ export { GenerateExtension as default };
@@ -0,0 +1 @@
1
+ export declare function syncExtensions(): Promise<void>;
@@ -0,0 +1,4 @@
1
+ async function syncExtensions() {
2
+ }
3
+
4
+ export { syncExtensions };
@@ -0,0 +1,3 @@
1
+ import type { Cli } from '@youcan/cli-kit';
2
+ import type { App, Extension } from '@/types';
3
+ export declare function bootExtensionWorker(command: Cli.Command, app: App, extension: Extension): Promise<import("@/types").ExtensionWorker>;
@@ -0,0 +1,13 @@
1
+ import ThemeExtensionWorker from './theme-extension-worker.js';
2
+
3
+ const EXTENSION_WORKERS = {
4
+ theme: ThemeExtensionWorker,
5
+ };
6
+ async function bootExtensionWorker(command, app, extension) {
7
+ const Ctor = EXTENSION_WORKERS[extension.config.type];
8
+ const worker = new Ctor(command, app, extension);
9
+ await worker.boot();
10
+ return worker;
11
+ }
12
+
13
+ export { bootExtensionWorker };
@@ -0,0 +1,15 @@
1
+ import type { Cli } from '@youcan/cli-kit';
2
+ import type { App, Extension, ExtensionWorker } from '@/types';
3
+ export default class ThemeExtensionWorker implements ExtensionWorker {
4
+ private command;
5
+ private app;
6
+ private extension;
7
+ FILE_TYPES: string[];
8
+ private EVENT_LOG_MAP;
9
+ private formatter;
10
+ constructor(command: Cli.Command, app: App, extension: Extension);
11
+ boot(): Promise<void>;
12
+ run(): Promise<void>;
13
+ private file;
14
+ private log;
15
+ }
@@ -0,0 +1,117 @@
1
+ import { Color, Session, Http, Env, Path, Filesystem, Crypto, Form } from '@youcan/cli-kit';
2
+
3
+ class ThemeExtensionWorker {
4
+ command;
5
+ app;
6
+ extension;
7
+ FILE_TYPES = [
8
+ 'assets',
9
+ 'locales',
10
+ 'snippets',
11
+ 'blocks',
12
+ ];
13
+ EVENT_LOG_MAP = {
14
+ error: () => Color.bold().red('[error]'),
15
+ add: () => Color.bold().green('[created]'),
16
+ change: () => Color.bold().blue('[updated]'),
17
+ unlink: () => Color.bold().yellow('[deleted]'),
18
+ };
19
+ formatter = Intl.NumberFormat('en', {
20
+ unitDisplay: 'narrow',
21
+ notation: 'compact',
22
+ style: 'unit',
23
+ unit: 'byte',
24
+ });
25
+ constructor(command, app, extension) {
26
+ this.command = command;
27
+ this.app = app;
28
+ this.extension = extension;
29
+ }
30
+ async boot() {
31
+ const session = await Session.authenticate(this.command);
32
+ try {
33
+ const res = await Http.post(`${Env.apiHostname()}/apps/draft/${this.app.config.id}/extensions/create`, {
34
+ headers: { Authorization: `Bearer ${session.access_token}` },
35
+ body: JSON.stringify({ ...this.extension.config }),
36
+ });
37
+ this.extension.id = res.id;
38
+ this.extension.metadata = res.metadata;
39
+ for (const type of Object.keys(this.extension.metadata)) {
40
+ const descriptors = this.extension.metadata[type];
41
+ const directory = Path.resolve(this.extension.root, type);
42
+ const present = await Filesystem.readdir(Path.resolve(directory));
43
+ present.filter(f => !descriptors.find(d => d.file_name === f))
44
+ .forEach(async (file) => await this.file('put', type, file));
45
+ descriptors.forEach(async (descriptor) => {
46
+ const path = Path.resolve(directory, descriptor.file_name);
47
+ if (!(await Filesystem.exists(path))) {
48
+ return await this.file('del', type, descriptor.file_name);
49
+ }
50
+ const buff = await Filesystem.readFile(path);
51
+ if (Crypto.sha1(buff) !== descriptor.hash) {
52
+ await this.file('put', type, descriptor.file_name);
53
+ }
54
+ });
55
+ }
56
+ }
57
+ catch (err) {
58
+ this.command.error(err);
59
+ }
60
+ }
61
+ async run() {
62
+ const paths = this.FILE_TYPES
63
+ .map(p => Path.resolve(this.extension.root, p));
64
+ const watcher = Filesystem.watch(paths, {
65
+ persistent: true,
66
+ ignoreInitial: true,
67
+ awaitWriteFinish: {
68
+ stabilityThreshold: 50,
69
+ },
70
+ });
71
+ watcher.on('all', async (event, path, stat) => {
72
+ try {
73
+ if (!['add', 'change', 'unlink'].includes(event)) {
74
+ return;
75
+ }
76
+ const start = new Date().getTime();
77
+ const [filetype, filename] = [
78
+ Path.basename(Path.dirname(path)),
79
+ Path.basename(path),
80
+ ];
81
+ let size = 0;
82
+ switch (event) {
83
+ case 'add':
84
+ case 'change':
85
+ size = (await this.file('put', filetype, filename)).size;
86
+ break;
87
+ case 'unlink':
88
+ size = (await this.file('del', filetype, filename)).size;
89
+ break;
90
+ }
91
+ this.log(event, Path.join(filetype, filename), this.formatter.format(stat.size), new Date().getTime() - start);
92
+ }
93
+ catch (err) {
94
+ this.log('error', path);
95
+ this.command.error(err);
96
+ }
97
+ });
98
+ }
99
+ async file(op, type, name) {
100
+ const path = Path.resolve(this.extension.root, type, name);
101
+ return await Http.post(`${Env.apiHostname()}/apps/draft/${this.app.config.id}/extensions/${this.extension.id}/file`, {
102
+ body: Form.convert({
103
+ file_name: name,
104
+ file_type: type,
105
+ file_operation: op,
106
+ file_content: await Form.file(path),
107
+ }),
108
+ });
109
+ }
110
+ log(event, path, size, time) {
111
+ const tag = this.EVENT_LOG_MAP[event]();
112
+ `${tag} ${Color.underline().white(path)}`;
113
+ this.command.log(`${tag} ${Color.underline().white(path)} - ${size} | ${time}ms \n`);
114
+ }
115
+ }
116
+
117
+ export { ThemeExtensionWorker as default };
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../../../types").ExtensionTemplate[];
2
+ export default _default;
@@ -0,0 +1,7 @@
1
+ import ThemeExtension from './theme-extension.js';
2
+
3
+ var extensions = [
4
+ ThemeExtension,
5
+ ];
6
+
7
+ export { extensions as default };
@@ -0,0 +1,3 @@
1
+ import type { ExtensionTemplate } from '@/types';
2
+ declare const _default: ExtensionTemplate;
3
+ export default _default;
@@ -0,0 +1,20 @@
1
+ var ThemeExtension = {
2
+ name: 'Theme extension',
3
+ identifier: 'theme-extension',
4
+ description: 'Liquid snippets to extend store themes',
5
+ types: [
6
+ {
7
+ url: 'https://github.com/youcan-shop/app-extension-templates',
8
+ type: 'theme',
9
+ flavors: [
10
+ {
11
+ name: 'Liquid',
12
+ value: 'liquid',
13
+ path: 'theme-extension/liquid',
14
+ },
15
+ ],
16
+ },
17
+ ],
18
+ };
19
+
20
+ export { ThemeExtension as default };
@@ -0,0 +1,10 @@
1
+ import type { ExtensionFlavor, ExtensionTemplateType, InitialAppConfig } from '@/types';
2
+ export declare function ensureExtensionDirectoryExists(name: string): Promise<string>;
3
+ export interface InitExtensionOptions {
4
+ name: string;
5
+ app: InitialAppConfig;
6
+ directory: string;
7
+ type: ExtensionTemplateType;
8
+ flavor?: ExtensionFlavor;
9
+ }
10
+ export declare function initThemeExtension(options: InitExtensionOptions): Promise<void>;
@@ -0,0 +1,30 @@
1
+ import { Path, Filesystem, Git } from '@youcan/cli-kit';
2
+ import { EXTENSION_CONFIG_FILENAME } from '../../../constants.js';
3
+
4
+ async function ensureExtensionDirectoryExists(name) {
5
+ const dir = Path.join(Path.cwd(), 'extensions', name);
6
+ if (await Filesystem.exists(dir)) {
7
+ throw new Error(`The '${name}' already exists, choose a new name for your extension`);
8
+ }
9
+ await Filesystem.mkdir(dir);
10
+ return dir;
11
+ }
12
+ async function initThemeExtension(options) {
13
+ return Filesystem.tapIntoTmp(async (tmp) => {
14
+ const directory = Path.join(tmp, 'download');
15
+ await Filesystem.mkdir(directory);
16
+ await Git.clone({
17
+ url: options.type.url,
18
+ destination: directory,
19
+ shallow: true,
20
+ });
21
+ const flavorPath = Path.join(directory, options.flavor?.path || '');
22
+ if (!await Filesystem.exists(flavorPath)) {
23
+ throw new Error(`Extension flavor '${options.flavor?.name}' is unavailble`);
24
+ }
25
+ await Filesystem.move(flavorPath, options.directory, { overwrite: true });
26
+ await Filesystem.writeJsonFile(Path.join(options.directory, EXTENSION_CONFIG_FILENAME), { name: options.name, type: options.type.type });
27
+ });
28
+ }
29
+
30
+ export { ensureExtensionDirectoryExists, initThemeExtension };
@@ -0,0 +1,3 @@
1
+ export declare const APP_CONFIG_FILENAME = "youcan.app.json";
2
+ export declare const EXTENSION_CONFIG_FILENAME = "youcan.extension.json";
3
+ export declare const DEFAULT_EXTENSIONS_DIR = "extensions/";
@@ -0,0 +1,5 @@
1
+ const APP_CONFIG_FILENAME = 'youcan.app.json';
2
+ const EXTENSION_CONFIG_FILENAME = 'youcan.extension.json';
3
+ const DEFAULT_EXTENSIONS_DIR = 'extensions/';
4
+
5
+ export { APP_CONFIG_FILENAME, DEFAULT_EXTENSIONS_DIR, EXTENSION_CONFIG_FILENAME };
@@ -0,0 +1,3 @@
1
+ export declare const APP_FLAGS: {
2
+ path: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
3
+ };
package/dist/flags.js ADDED
@@ -0,0 +1,13 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { Path } from '@youcan/cli-kit';
3
+
4
+ const APP_FLAGS = {
5
+ path: Flags.string({
6
+ env: 'YC_FLAG_PATH',
7
+ default: async () => Path.cwd(),
8
+ parse: async (input) => Path.resolve(input),
9
+ description: 'The path to your app directory.',
10
+ }),
11
+ };
12
+
13
+ export { APP_FLAGS };
@@ -0,0 +1,63 @@
1
+ import type { Cli } from '@youcan/cli-kit';
2
+ export interface InitialAppConfig {
3
+ [key: string]: unknown;
4
+ name: string;
5
+ }
6
+ export interface ExtensionWorkerConstructor<T extends Extension = Extension> {
7
+ new (command: Cli.Command, app: App, extension: T): ExtensionWorker;
8
+ }
9
+ export interface ExtensionWorker {
10
+ run(): Promise<void>;
11
+ boot(): Promise<void>;
12
+ }
13
+ export type AppConfig = {
14
+ id: string;
15
+ url: string;
16
+ oauth: {
17
+ client_id: string;
18
+ scopes: string[];
19
+ };
20
+ } & InitialAppConfig;
21
+ export interface ExtensionConfig {
22
+ [key: string]: unknown;
23
+ type: string;
24
+ name: string;
25
+ }
26
+ export interface ExtensionFlavor {
27
+ name: string;
28
+ path?: string;
29
+ value: 'liquid';
30
+ }
31
+ export interface ExtensionTemplateType {
32
+ url: string;
33
+ type: string;
34
+ flavors: ExtensionFlavor[];
35
+ }
36
+ export interface ExtensionTemplate {
37
+ identifier: string;
38
+ name: string;
39
+ description: string;
40
+ types: ExtensionTemplateType[];
41
+ }
42
+ export interface Extension {
43
+ id?: string;
44
+ metadata?: ExtensionMetadata;
45
+ root: string;
46
+ config: ExtensionConfig;
47
+ }
48
+ export interface App {
49
+ root: string;
50
+ config: AppConfig;
51
+ extensions: Extension[];
52
+ }
53
+ export interface ExtensionFileDescriptor {
54
+ id: string;
55
+ type: string;
56
+ name: string;
57
+ file_name: string;
58
+ size: number;
59
+ hash: string;
60
+ }
61
+ export interface ExtensionMetadata {
62
+ [key: string]: ExtensionFileDescriptor[];
63
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,2 @@
1
+ import type { App } from '@/types';
2
+ export declare function load(): Promise<App>;
@@ -0,0 +1,27 @@
1
+ import { Path, Filesystem } from '@youcan/cli-kit';
2
+ import { APP_CONFIG_FILENAME, DEFAULT_EXTENSIONS_DIR, EXTENSION_CONFIG_FILENAME } from '../constants.js';
3
+
4
+ async function load() {
5
+ const path = Path.resolve(Path.cwd(), APP_CONFIG_FILENAME);
6
+ if (!await Filesystem.exists) {
7
+ throw new Error(`app config not found at ${path}`);
8
+ }
9
+ const config = await Filesystem.readJsonFile(path);
10
+ const app = {
11
+ config,
12
+ extensions: [],
13
+ root: Path.cwd(),
14
+ };
15
+ const pattern = Path.join(app.root, `${DEFAULT_EXTENSIONS_DIR}/*`, EXTENSION_CONFIG_FILENAME);
16
+ const paths = await Filesystem.glob(pattern);
17
+ const promises = paths.map(async (p) => {
18
+ return {
19
+ root: Path.dirname(p),
20
+ config: await Filesystem.readJsonFile(p),
21
+ };
22
+ });
23
+ app.extensions = await Promise.all(promises);
24
+ return app;
25
+ }
26
+
27
+ export { load };
@@ -0,0 +1,3 @@
1
+ import { Cli } from '@youcan/cli-kit';
2
+ export declare abstract class AppCommand extends Cli.Command {
3
+ }
@@ -0,0 +1,6 @@
1
+ import { Cli } from '@youcan/cli-kit';
2
+
3
+ class AppCommand extends Cli.Command {
4
+ }
5
+
6
+ export { AppCommand };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@youcan/app",
3
+ "type": "module",
4
+ "version": "1.1.0-beta.2",
5
+ "description": "OCLIF plugin for building apps",
6
+ "author": "YouCan <contact@youcan.shop> (https://youcan.shop)",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "youcan",
10
+ "youcan-cli",
11
+ "youcan-app"
12
+ ],
13
+ "files": [
14
+ "dist",
15
+ "oclif.manifest.json"
16
+ ],
17
+ "dependencies": {
18
+ "@oclif/core": "^2.15.0",
19
+ "@youcan/cli-kit": "1.1.0-beta.2"
20
+ },
21
+ "devDependencies": {
22
+ "@oclif/plugin-legacy": "^1.3.0",
23
+ "@types/node": "^18.18.0",
24
+ "shx": "^0.3.4"
25
+ },
26
+ "oclif": {
27
+ "commands": "./dist/cli/commands"
28
+ },
29
+ "scripts": {
30
+ "build": "shx rm -rf dist && tsc --noEmit && rollup --config rollup.config.js",
31
+ "dev": "shx rm -rf dist && rollup --config rollup.config.js --watch",
32
+ "release": "pnpm publish --access public",
33
+ "type-check": "tsc --noEmit"
34
+ }
35
+ }