diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..068a32e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,9 @@
+# Chrome Web Store Instructions
+
+The following environment variables are configured on the host system:
+- `CHROME_CLIENT_ID`
+- `CHROME_CLIENT_SECRET`
+- `CHROME_REFRESH_TOKEN`
+
+## Chrome Extension Details
+- **Extension ID:** `dkelmgdchndagjemmodhkphdikhpnfol`
\ No newline at end of file
diff --git a/manifest.json b/manifest.json
index fcd5520..b8e43df 100644
--- a/manifest.json
+++ b/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": 3,
"minimum_chrome_version": "138",
"name": "OpenScript",
- "version": "1.0.2",
+ "version": "1.0.3",
"description": "A lightweight user script manager for modern browsers",
"action": {
"default_popup": "src/popup.html",
diff --git a/package-lock.json b/package-lock.json
index 4c567e3..5722014 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "openscript",
- "version": "1.0.2",
+ "version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openscript",
- "version": "1.0.2",
+ "version": "1.0.3",
"dependencies": {
"lucide": "^0.475.0"
},
diff --git a/package.json b/package.json
index 1782eef..eddace8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "openscript",
- "version": "1.0.2",
+ "version": "1.0.3",
"private": true,
"type": "module",
"scripts": {
@@ -9,6 +9,7 @@
"build:icons": "node scripts/generate-icons.js",
"screenshot": "node scripts/make-screenshot.js",
"zip": "node scripts/build-zip.js",
+ "publish": "node scripts/publish.js",
"test": "node --test"
},
"dependencies": {
diff --git a/scripts/publish.js b/scripts/publish.js
new file mode 100644
index 0000000..fc68339
--- /dev/null
+++ b/scripts/publish.js
@@ -0,0 +1,84 @@
+import fs from 'fs';
+
+const EXTENSION_ID = 'dkelmgdchndagjemmodhkphdikhpnfol';
+const { CHROME_CLIENT_ID, CHROME_CLIENT_SECRET, CHROME_REFRESH_TOKEN } = process.env;
+
+if (!CHROME_CLIENT_ID || !CHROME_CLIENT_SECRET || !CHROME_REFRESH_TOKEN) {
+ console.error('Missing Chrome Web Store credentials in environment variables.');
+ process.exit(1);
+}
+
+const zipPath = 'openscript.zip';
+if (!fs.existsSync(zipPath)) {
+ console.error(`Zip file not found: ${zipPath}`);
+ process.exit(1);
+}
+
+async function publish() {
+ console.log('1. Refreshing access token...');
+ const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ client_id: CHROME_CLIENT_ID,
+ client_secret: CHROME_CLIENT_SECRET,
+ refresh_token: CHROME_REFRESH_TOKEN,
+ grant_type: 'refresh_token',
+ }),
+ });
+
+ const tokenData = await tokenRes.json();
+ if (!tokenRes.ok) {
+ console.error('Failed to obtain access token:', tokenData);
+ process.exit(1);
+ }
+
+ const accessToken = tokenData.access_token;
+ console.log('✓ Access token obtained.');
+
+ console.log(`2. Uploading ${zipPath} to Chrome Web Store (${EXTENSION_ID})...`);
+ const zipBuffer = fs.readFileSync(zipPath);
+ const uploadRes = await fetch(
+ `https://www.googleapis.com/upload/chromewebstore/v1.1/items/${EXTENSION_ID}`,
+ {
+ method: 'PUT',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'x-goog-api-version': '2',
+ },
+ body: zipBuffer,
+ }
+ );
+
+ const uploadData = await uploadRes.json();
+ if (!uploadRes.ok || uploadData.uploadState !== 'SUCCESS') {
+ console.error('Upload failed:', uploadData);
+ process.exit(1);
+ }
+ console.log('✓ Upload successful:', uploadData.uploadState);
+
+ console.log('3. Publishing new version to Chrome Web Store...');
+ const publishRes = await fetch(
+ `https://www.googleapis.com/chromewebstore/v1.1/items/${EXTENSION_ID}/publish`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'x-goog-api-version': '2',
+ 'Content-Length': '0',
+ },
+ }
+ );
+
+ const publishData = await publishRes.json();
+ if (!publishRes.ok || (publishData.status && !publishData.status.includes('OK'))) {
+ console.error('Publish failed:', publishData);
+ process.exit(1);
+ }
+ console.log('✓ Publish response:', publishData);
+}
+
+publish().catch(err => {
+ console.error('Error during publish:', err);
+ process.exit(1);
+});
diff --git a/src/popup.js b/src/popup.js
index 80f8665..2e96710 100644
--- a/src/popup.js
+++ b/src/popup.js
@@ -1,7 +1,7 @@
import {
getScripts, saveScripts, getSecrets, saveSecrets, garbageCollectScriptStorage,
} from './utils/storage.js';
-import { parseMeta, getBoilerplate } from './utils/parser.js';
+import { parseMeta, getBoilerplate, getMetaRunAt } from './utils/parser.js';
import { isUserScriptsAvailable } from './utils/userScripts.js';
import { renderIcons, icon } from './utils/icons.js';
import { VERSION } from './version.js';
@@ -44,6 +44,7 @@ const init = async () => {
state.scripts = scripts.map(s => ({
...s,
version: parseMeta(s.code || '').version,
+ runAt: getMetaRunAt(s.code || '') || s.runAt || 'document_idle',
}));
state.secrets = secrets;
render();
@@ -92,7 +93,7 @@ const saveCurrentScript = async () => {
requires: meta.requires,
requireCache: existing?.requireCache || {},
storageToken: existing?.storageToken,
- runAt: $('#run-at-select')?.value || meta.runAt || 'document_idle',
+ runAt: getMetaRunAt(code) || $('#run-at-select')?.value || 'document_idle',
code,
enabled: existing ? existing.enabled : true,
updatedAt: Date.now(),
@@ -252,7 +253,7 @@ const renderScriptList = () => {
const renderEditor = () => {
const script = state.scripts.find(s => s.id === state.editingId);
const code = script ? script.code : getBoilerplate();
- const runAt = script?.runAt || 'document_idle';
+ const runAt = getMetaRunAt(code) || script?.runAt || 'document_idle';
return `
@@ -458,12 +459,23 @@ const bindEvents = () => {
$('#btn-cancel-edit')?.addEventListener('click', () => setTab('list'));
$('#btn-reset-boilerplate')?.addEventListener('click', () => {
const el = $('#editor-code');
- if (el && confirm('Reset code to default boilerplate?')) el.value = getBoilerplate();
+ if (el && confirm('Reset code to default boilerplate?')) {
+ el.value = getBoilerplate();
+ const sel = $('#run-at-select');
+ const script = state.scripts.find(s => s.id === state.editingId);
+ if (sel) sel.value = script?.runAt || 'document_idle';
+ }
});
- // Tab key indent in editor
+ // Tab key indent & metadata sync in editor
const textarea = $('#editor-code');
if (textarea) {
+ textarea.addEventListener('input', () => {
+ const metaRunAt = getMetaRunAt(textarea.value);
+ const sel = $('#run-at-select');
+ if (sel && metaRunAt) sel.value = metaRunAt;
+ });
+
textarea.addEventListener('keydown', e => {
if (e.key === 'Tab') {
e.preventDefault();
diff --git a/src/utils/parser.js b/src/utils/parser.js
index 548c3bf..f4c01eb 100644
--- a/src/utils/parser.js
+++ b/src/utils/parser.js
@@ -2,6 +2,13 @@
const SINGLE_KEYS = new Set(['name', 'version', 'description', 'run-at']);
const MULTI_KEYS = new Set(['match', 'require']);
+const RUN_AT_OPTIONS = new Set(['document_start', 'document_end', 'document_idle']);
+
+export const getMetaRunAt = code => {
+ const block = code.match(/\/\/ ==UserScript==([\s\S]*?)\/\/ ==\/UserScript==/)?.[1] || '';
+ const val = block.match(/\/\/\s*@run-at\s+([\w-]+)/i)?.[1]?.toLowerCase().replace('-', '_');
+ return RUN_AT_OPTIONS.has(val) ? val : null;
+};
export const parseMeta = code => {
const block = code.match(/\/\/ ==UserScript==([\s\S]*?)\/\/ ==\/UserScript==/)?.[1] || '';
diff --git a/src/utils/userScripts.js b/src/utils/userScripts.js
index cbd0af9..5f2ab3b 100644
--- a/src/utils/userScripts.js
+++ b/src/utils/userScripts.js
@@ -1,5 +1,5 @@
import { getScripts, saveScripts, getSecrets } from './storage.js';
-import { normalizeMatch, parseMeta } from './parser.js';
+import { normalizeMatch, parseMeta, getMetaRunAt } from './parser.js';
import { VERSION } from '../version.js';
const STORAGE_MESSAGE = 'OPEN_SCRIPT_STORAGE';
@@ -91,14 +91,16 @@ export const syncUserScripts = async ({ refreshRequires = false } = {}) => {
scripts = scripts.map(script => {
const requires = parseMeta(script.code || '').requires;
const storageToken = script.storageToken || crypto.randomUUID();
+ const runAt = getMetaRunAt(script.code || '') || script.runAt || 'document_idle';
if (requires.some(url => script.requireCache?.[url] === undefined)) missingCache = true;
if (storageToken === script.storageToken &&
+ runAt === script.runAt &&
JSON.stringify(requires) === JSON.stringify(script.requires || [])) return script;
changed = true;
const requireCache = Object.fromEntries(requires.flatMap(url =>
script.requireCache && Object.hasOwn(script.requireCache, url) ? [[url, script.requireCache[url]]] : []
));
- return { ...script, requires, requireCache, storageToken };
+ return { ...script, runAt, requires, requireCache, storageToken };
});
let warnings = [];
diff --git a/src/version.js b/src/version.js
index 926d3f2..30cbd1a 100644
--- a/src/version.js
+++ b/src/version.js
@@ -1 +1 @@
-export const VERSION = '1.0.2';
+export const VERSION = '1.0.3';
diff --git a/tests/parser.test.js b/tests/parser.test.js
index cb906c4..a8e3b14 100644
--- a/tests/parser.test.js
+++ b/tests/parser.test.js
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { parseMeta, normalizeMatch, getBoilerplate } from '../src/utils/parser.js';
+import { parseMeta, normalizeMatch, getBoilerplate, getMetaRunAt } from '../src/utils/parser.js';
import {
buildScriptCode, refreshRequireCaches, syncUserScripts, wrapScriptCode,
} from '../src/utils/userScripts.js';
@@ -39,6 +39,33 @@ test('parseMeta falls back to defaults when fields are missing', () => {
assert.deepEqual(meta.requires, []);
});
+test('getMetaRunAt parses valid @run-at directives and ignores invalid or absent ones', () => {
+ assert.equal(getMetaRunAt('// ==UserScript==\n// @run-at document-start\n// ==/UserScript=='), 'document_start');
+ assert.equal(getMetaRunAt('// ==UserScript==\n// @run-at document_end\n// ==/UserScript=='), 'document_end');
+ assert.equal(getMetaRunAt('// ==UserScript==\n// @run-at document-idle\n// ==/UserScript=='), 'document_idle');
+ assert.equal(getMetaRunAt('// ==UserScript==\n// @run-at DOCUMENT-START\n// ==/UserScript=='), 'document_start');
+ assert.equal(getMetaRunAt('// ==UserScript==\n// @name Test\n// ==/UserScript=='), null);
+ assert.equal(getMetaRunAt('// @run-at document-start outside header'), null);
+ assert.equal(getMetaRunAt('// ==UserScript==\n// @run-at invalid-timing\n// ==/UserScript=='), null);
+});
+
+test('metadata run-at header overrides selector and falls back when absent', () => {
+ const resolveRunAt = (code, selectorVal, storedVal) =>
+ getMetaRunAt(code) || selectorVal || storedVal || 'document_idle';
+
+ const codeWithHeader = '// ==UserScript==\n// @run-at document-start\n// ==/UserScript==';
+ const codeWithoutHeader = '// ==UserScript==\n// @name Script\n// ==/UserScript==';
+
+ // First time creation: metadata overrides selector
+ assert.equal(resolveRunAt(codeWithHeader, 'document_idle', undefined), 'document_start');
+ // First time creation: no metadata uses selector
+ assert.equal(resolveRunAt(codeWithoutHeader, 'document_end', undefined), 'document_end');
+ // Opening to edit: metadata overrides stored value
+ assert.equal(resolveRunAt(codeWithHeader, null, 'document_idle'), 'document_start');
+ // Opening to edit: no metadata falls back to stored value
+ assert.equal(resolveRunAt(codeWithoutHeader, null, 'document_end'), 'document_end');
+});
+
test('normalizeMatch formats URL patterns for Chrome userScripts API', () => {
assert.equal(normalizeMatch('https://example.com'), 'https://example.com/*');
assert.equal(normalizeMatch('example.com/*'), '*://example.com/*');